HarmonyOS 鸿蒙Next 基于ArkTS和Stage模型,我开发了记账应用

HarmonyOS 鸿蒙Next 基于ArkTS和Stage模型,我开发了记账应用 #HarmonyOS体验官 基于ArkTS和Stage模型,我开发了记账应用

1 前言

我一直有想做一款属于自己的记账应用的想法,恰好前段时间学习了基于ArkUI的HarmonyOS应用开发,现在终于能自己做出个像样的HarmonyOS App了。目前已完成账本、账户、账单的查看,以及添加账单的功能,添加账本和账户还未实现。个人很喜欢鸿蒙系统的蓝白色搭配和卡片式组件的风格,所以这个App整体参考了HarmonyOS系统应用的风格,使用经典的蓝色、白色、浅灰配色,比较简洁。

之前使用过一些记账App,基本都有设计独立的数字键盘,有些还提供计算功能,于是我也试着实现了数字键盘加计算的功能。因此,在整个应用里面我觉得难度最大的一个模块就是表达式计算了,虽然在数据结构与算法的实验课上做过实验,但是在这里情况有些差异,而且我对这个算法的详细步骤不是非常熟悉,实现的过程相对还是比较曲折的。总之就是不断调试、不断调试,最后达到能用的程度。

因篇幅有限,部分代码(包括组件样式属性,获取数据相关的方法)在此不作展示,相关API的详细使用方法也不作说明,如有需要请查阅官方文档。

记账应用,包括表达式计算的模块(点击蓝色字体进入仓库),我都放在Gitee上开源了,欢迎大家查看。

本项目用到的关键组件、API和重要知识

名称(文档链接) 说明(内容摘自OpenHarmony文档)
Tabs 一种可以通过页签进行内容视图切换的容器组件,每个页签对应一个内容视图。
SideBarContainer 提供侧边栏可以显示和隐藏的侧边栏容器,通过子组件定义侧边栏和内容区,第一个子组件表示侧边栏,第二个子组件表示内容区。
List 列表包含一系列相同宽度的列表项。适合连续、多行呈现同类数据,例如图片和文本。
Grid 网格容器,由“行”和“列”分割的单元格所组成,通过指定“项目”所在的单元格做出各种各样的布局。
Select 提供下拉选择菜单,可以让用户在多个选项之间选择。
绑定手势(主要用到panGesture) 为组件绑定不同类型的手势事件,并设置事件的响应方法。
页面级变量的状态管理(主要用到@State@Provide@Consume@Watch) @State@Prop@Link@Provide、Consume、@ObjectLink@Observed@Watch用于管理页面级变量的状态。
应用级变量的状态管理(主要用到AppStorage) 状态管理模块提供了应用程序的数据存储能力、持久化数据管理能力、Ability数据存储能力和应用程序需要的环境状态。

预览效果

预览效果

2 项目搭建

本项目使用的IDE版本:

3 开发过程

3.1 定义数据结构

主要定义三种数据结构:账户、账本和账单。

账户

export class Account {
  id: number
  name: string //账户名称
  type: string //账户类型
  currency: string //币种
  balance: number //余额
  icon: Resource //图标,本示例中未使用
  remark: string //备注
    ...(构造方法略)
}

账本

export class AccountBook {
  id: number
  name: string //账本名称
  icon: Resource //图标,本实例中未使用
  remark: string //备注
    ...(构造方法略)
}

账单

export class Bill {
  id: number
  consumption: number //账单金额
  time: number //记录时间
  type: BillType //账单类型
  remark: string //备注
  spend: boolean //支出or收入
  accountId: number //账户ID
  accountBookId: number //账本ID
    ...(构造方法略)
}

export enum BillTypeId {
  Meal = 1, Daily, Shopping, Amusement, Clothing, Medical ,Education, Digital
}

export class BillType {
  id: BillTypeId
  name: string //账单类型的名称

  constructor(id: BillTypeId) {
    this.id = id;
    switch (id) {
      case BillTypeId.Meal:
        this.name = '餐饮';
        break;
      ...(剩余部分略)
    }
  }
}

3.2 实现ViewModel

这里仅以获取所有账单数据为例。

首先调用AppStorage.Has()方法判断AppStorage否已经持有所有的账单,若无,则取出模拟的账单数据存进AppStorage中,否则用AppStorage.Get()方法取出AppStorage中的数据返回。

//使用了单例模式
export class BillViewModel extends Singleton<BillViewModel> {
  getAllBills(): Bill[] {
    if (!AppStorage.Has('allBills')) {
      let bills = mockBills;
      AppStorage.SetOrCreate('allBills', bills);
    }
    return AppStorage.Get<Bill[]>('allBills');
  ...
}

3.3 使用Tabs组件搭建入口页面

3.3.1 新建Page页面,名为Main。
3.3.2 在build()函数中添加Tabs组件,指定TabBar的位置,实例化TabsController对象赋值给变量tabController,将其设置为Tabs的controller。
@Entry
@Component
struct Main {
  private tabController: TabsController = new TabsController();

  build() {
    Tabs({ barPosition: BarPosition.End, controller: this.tabController }) {}
  }
}
3.3.3 使用@Builder装饰器编写TabBar布局。
@Entry
@Component
struct Main {
    ...
  getFillColor(index: number) { //根据当前的TabBar索引切换填充颜色
    if(index == 1) return null; //中间的TabBar不填充颜色
    return this.currentIndex == index ? MainConstants.TAB_BAR_ACTIVE_COLOR : MainConstants.TAB_BAR_INACTIVE_COLOR;
  }

  [@Builder](/user/Builder) TabBarBuilder(index: number, img: Resource) {
    Column() {
      Image(img)
        .height(MainConstants.TAB_BAR_IMG_SIZE)
        .aspectRatio(1)
        .fillColor(this.getFillColor(index))
        .objectFit(ImageFit.Contain)
        .opacity(index === this.currentIndex ? 1 : 0.4)
    }.width('100%').height('100%').justifyContent(FlexAlign.Center)
  }
    ...
}
3.3.4 在Tabs中添加TabContent(),并设置TabBar。
  build() {
    Tabs({ barPosition: BarPosition.End, controller: this.tabController }) {
      TabContent() {
        //BillPage()
      }.tabBar(this.TabBarBuilder(0, $r('app.media.ic_public_bills_filled')))

      TabContent() {
        //AddPage()
      }.tabBar(this.TabBarBuilder(1, $r('app.media.ic_public_add_light')))

      TabContent() {
        //AccountPage()
      }.tabBar(this.TabBarBuilder(2, $r('app.media.ic_public_accounts_filled')))
    }
  }
3.3.5 添加用@State装饰器修饰的currentIndex变量,用来记录当前选中的TabBar索引。完成对Tabs属性的配置:包括TabBar的高度、显示模式、是否可滑动切换页面,绑定onChange事件更新当前索引。
    Tabs({...}){...}
    .barHeight(MainConstants.TAB_BAR_HEIGHT)
    .barMode(BarMode.Fixed)
    .scrollable(false)
    .onChange(index => {
      this.currentIndex = index;
    })
    .backgroundColor($r('app.color.page_background_color'))
3.3.6 预览效果

预览效果

3.4 编写第一个Tab页面:账单页面

在common/views/bill目录下新建BillPage.ets文件。

3.4.1 自定义账本列表组件
3.4.1.1 在common/views/bill目录下新建AccountBooksList.ets文件。
3.4.1.2 定义账本列表项。
@Component
struct AccountBookItem {
  data: AccountBook;

  build() {
    Column() {
      Row() {
        Image($r('app.media.app_icon'))...
        Text(this.data.name)...
      }...
      Blank()
      Text(this.data.remark)...
    }...
  }
}
3.4.1.3 定义账本列表。
@Component
export struct AccountBooksList {
  books: AccountBook[];
    
  build() {
    Scroll() {
      Column() {
        ForEach(this.books, (book: AccountBook) => {
          AccountBookItem({ data: book })
        }, book => book.id)
      }
    }...
  }
}
3.4.2 自定义账单列表组件

在common/views/bill目录下新建BillsList.ets文件。

@Component
export struct BillsList {
  [@Consume](/user/Consume)('currentBills') data: Bill[];

  build() {
 Column({ space: BillConstants.BILL_LIST_COLUMN_SPACE }) {
   Text('清单')...
   Column() {
     if (this.data.length == 0) {
       Text('暂时还没有记录哦')...
     }
     List() {
       ForEach(this.data, (bill: Bill) => {
         ListItem() {
           BillItem({ data: bill })
         }
       })
     }
   }...
 }
  }
}
   
@Component
struct BillItem {
  [@State](/user/State) data: Bill = undefined;

  build() {
 Row() {
   Text(`${this.data.remark}`)...
   Blank()
   Text(`${this.data.spend ? '-' : '+'}${this.data.consumption}`)...
 }...
  }
}
3.4.3 使用SideBarContainer实现侧边栏切换账本
3.4.3.1 加入SideBarContainer组件,在SideBarContainer中包括两部分内容,一是侧边栏(账本列表),二是内容区(当前选择账本的账单列表)。使用了@Provide装饰器定义了账本列表、当前账本、是否显示侧边栏这几个变量,以便于在子组件中做数据绑定。
@Component
export struct BillPage {
  //所有账本
  [@Provide](/user/Provide)('accountBooks') accountBooks: AccountBook[] = ...;
  //当前选择的账本
  [@Provide](/user/Provide)('currentBook') [@Watch](/user/Watch)('onCurrentBookChange') currentBook: AccountBook = ...; 
  //当前账本下的账单数据
  [@Provide](/user/Provide)('currentBills') bills: Bill[] = [];
  //控制侧边栏可见性
  [@Provide](/user/Provide)('showSideBar') showSideBar: boolean = false; 

  aboutToAppear() { //初始化账单数据
    this.bills = ...;
  }

  onCurrentBookChange() { //切换账本后触发该方法,更新账单数据
    this.bills = ...;
  }
    
  build() {
    Column() {
      SideBarContainer(SideBarContainerType.Overlay) {
        AccountBooksList()
        SideBarContent()
      }...
    }...
    //绑定向左滑动和向右滑动的手势,控制侧边栏的可见性
    .gesture(PanGesture({ direction: PanDirection.Left | PanDirection.Right })
      .onActionEnd(event => {
        this.showSideBar = event.offsetX > 0 ? true : false;
      })
    )
  }
}
3.4.3.2 定义SideBarContainer内容区组件SideBarContent。使用@Consume装饰器获取父组件BillPage提供的数据,TitleBar用于显示控制侧边栏的按钮和当前账本的名称,BillsList为当前账本下的账单列表。
@Component
struct SideBarContent {
  build() {
    Column({ space: PublicConstants.CARD_COLUMN_SPACE }) {
      TitleBar()...
      BillsList()
    }...
  }
}

@Component
struct TitleBar {
  [@Consume](/user/Consume)('currentBook') currentBook: AccountBook;
  [@Consume](/user/Consume)('showSideBar') showSideBar: boolean;
  
  build() {
    Row({ space: PublicConstants.TITLE_BAR_SPACE }) {
      //ImageButton为自定义的组件,这里就不作代码展示了
      ImageButton({ image: $r('app.media.ic_public_drawer'), onButtonClick: () => {
        this.showSideBar = !this.showSideBar;
      } })
      Text(this.currentBook.name)...
    }...
  }
}
3.4.3.3 在AccountBooksListItem中绑定相关数据完成账本切换功能。点击账本项时更新当前账本,并关闭侧边栏。
@Component
struct AccountBookItem {
  ...
  [@Consume](/user/Consume)('currentBook') current: AccountBook;
  [@Consume](/user/Consume)('showSideBar') showSideBar: boolean;

  build() {
    Column() {...}...
    .borderWidth(this.data.id == this.current.id ? 2 : 0)
    .borderColor(this.data.id == this.current.id ? $r('app.color.icon_highlight') : null)
    .onClick(()=>{
      this.current = this.data;
      this.showSideBar = false;
    })
  }
}
3.4.4 预览效果

预览效果

3.5 编写第二个Tab页面:账户页面

在common/views/account目录下新建AccountPage.ets文件。

3.5.1 自定义总资产卡片

在common/views/account目录下新建TotalAssetCard.ets文件。

@Component
export struct TotalAssetCard {
  [@Consume](/user/Consume)('accounts') accounts: Account[]; //所有的账户数据,由父组件AccountPage提供
  [@State](/user/State) hideAsset: boolean = true;    //是否隐藏总资产
  [@State](/user/State) totalAsset: number = 0; 

  aboutToAppear() {
    this.updateTotalAsset();
  }

  updateTotalAsset(){//计算总资产}

  build() {
    Column() {
      Column() {
        Row() {
          Text('总资产')...
          Image(this.hideAsset ? $r('app.media.ic_public_password_invisible') : $r('app.media.ic_public_password_visible'))
            ...
            .onClick(()=>{ //点击切换总资产的显示/隐藏
              this.hideAsset = !this.hideAsset;
            })
        }
        Text(this.hideAsset ? '¥****' : `¥${this.totalAsset}`)...
      }...
  }
}
3.5.2 自定义账户列表

在common/views/account目录下新建AccountsList.ets文件。

@Component
export struct AccountsList {
  [@Consume](/user/Consume)('accounts') accounts: Account[];

  build() {
    Column() {
      Text('全部账户')...
      List() {
        ForEach(this.accounts, (account: Account) => {
          ListItem() {
            AccountItem({ data: account })
          }
        })
      }.divider({ strokeWidth: 1 })
    }
  }
}

@Component
struct AccountItem {
  [@State](/user/State) data: Account = undefined;
  [@State](/user/State) hideBalance: boolean = true;

  build() {
    Column() {
      Column() {
        Row() {
          Text(this.data.name)...
          Blank()
          Image(this.hideBalance ? $r('app.media.ic_public_password_invisible') : $r('app.media.ic_public_password_visible'))
            ...
            .onClick(() => {
              this.hideBalance = !this.hideBalance;
            })
          Text(`¥${this.hideBalance ? '****' : `${this.data.balance}`}`)...
        }...
        Text(this.data.remark)...
        Blank()
        Text(this.data.type)...
      }.width('100%')
    }
  }
}
3.5.3 整合总资产卡片与账户列表,完成账户页面
@Component
export struct AccountPage {
  //获取所有账户数据,提供给子组件做数据绑定
  [@Provide](/user/Provide)('accounts') accounts: Account[] = ...;

  build() {
    Column() {
      TitleBar()...
      Scroll() {
        Column() {
          TotalAssetCard()
          AccountsList()
        }
      }...
    }...
  }
}

@Component
struct TitleBar {
  build() {
    Row() {
      Text('账户')...
      Blank()
      ImageButton({ image: $r('app.media.ic_public_more') }) //功能待添加
    }...
  }
}

3.6 编写第三个Tab页面:账单添加页面

在common/views/add目录下新建AddPage.ets文件。

3.6.1 自定义账单编辑组件

在common/views/add目录下新建BillEditor.ets文件。

@Component
export struct BillEditor {
  [@Consume](/user/Consume)('accountBooks') accountBooks: AccountBook[];
  [@Consume](/user/Consume)('accounts') accounts: Account[];
  [@Consume](/user/Consume)('billTypes') billTypes: BillType[];
  @StorageLink('currentBook') currentBook: AccountBook = ...; //同步BillPage页面选择的账本
  /**
  * 账单信息
  */
  [@Consume](/user/Consume)('bookSelection') bookSelection: AccountBook;
  [@Consume](/user/Consume)('accountSelection') accountSelection: Account;
  [@Consume](/user/Consume)('typeSelection') typeSelection: BillType;
  [@Consume](/user/Consume)('consumption') consumption: number; //在计算器中也会绑定这个变量,按下=键会更新该变量
  [@Consume](/user/Consume)('isSpend') isSpend: boolean;
  [@Consume](/user/Consume)('remark') remark: string;
  /**
  * 选择器选项
  */
  private accountSelect: SelectOption[] = [];
  private bookSelect: SelectOption[] = [];
  private billTypeSelect: SelectOption[] = [];

  setAccountSelections() {
    this.accounts.forEach(account => {
      let option = { value: account.name, icon: $r('app.media.icon') };
      this.accountSelect.push(option);
    })
  }

  setBookSelections() {...}

  setBillTypeSelections() {...}

  aboutToAppear() {
    this.setAccountSelections();
    this.setBookSelections();
    this.setBillTypeSelections();
  }

  build() {
    Column() {
      //账本选择
      Row() {
        Text('账本:')...
        Select(this.bookSelect)
             ...
          .selected(this.accountBooks.indexOf(this.currentBook))
          .onSelect(index => {
            this.bookSelection = this.accountBooks[index];
          })
      }...
      //账户选择
      Row() {
        Text('账户:')...
        Select(this.accountSelect)
          ...
          .onSelect(index => {
            this.accountSelection = this.accounts[index];
          })
      }...
      //账单类型选择
      Row() {
        Text('类型:')...
        Select(this.billTypeSelect)
          ...
          .onSelect(index => {
            this.typeSelection = this.billTypes[index];
          })
      }...
      //账单金额显示与支出/收入选择
      Row() {
        Text(this.isSpend ? '支出:' : '收入:')...
        Text(`${this.consumption}`)...
        Blank()
        Toggle({ type: ToggleType.Switch, isOn: this.isSpend })
          .onChange(value => {
            this.isSpend = value;
          })
      }...
      //账单备注
      Row() {
        Text('备注:')...
        TextInput({ text: this.remark }).layoutWeight(1)
          .onChange((value => {
            this.remark = value;
          }))
      }...
    }...
  }
}
3.6.2 自定义表达式计算器
3.6.2.1 在common/views/add目录下新建Calculator.ets文件。
3.6.2.2 定义TextButton组件,作为计算器中的操作按键。
@Component
struct TextButton {
  text: string | Resource
  fontColor: Color | string | Resource
  btnBackgroundColor: Color | string | Resource
  onBtnClick: () => void

  build() {
    Button(this.text, { type: ButtonType.Circle, stateEffect: true })
      .width(CalculatorConstants.BUTTON_SIZE)
      .height(CalculatorConstants.BUTTON_SIZE)
      .fontColor(this.fontColor ? this.fontColor : Color.Black)
      .fontSize(CalculatorConstants.BUTTON_FONT_SIZE)
      .backgroundColor(this.btnBackgroundColor ? this.btnBackgroundColor : Color.White)
      .padding((CalculatorConstants.BUTTON_PADDING))
      .onClick(this.onBtnClick)
  }
}
3.6.2.3 使用Grid组件构建计算器布局。
@Component
export struct Calculator {
  ...
  build() {
    Grid() {...}
    .width('100%')
    .rowsTemplate('1fr 1fr 1fr 1fr 1fr')
    .columnsTemplate('1fr 1fr 1fr 1fr')
    .rowsGap(CalculatorConstants.GRID_ROWS_GAP)
    .columnsGap(CalculatorConstants.GRID_COLUMNS_GAP)
    .height(CalculatorConstants.GRID_HEIGHT)
    .padding(CalculatorConstants.GRID_PADDING)
    .backgroundColor(CalculatorConstants.GRID_BACKGROUND_COLOR)
    .borderRadius(CalculatorConstants.GRID_BORDER_RADIUS)
  }
}
3.6.2.4 使用ForEach快速添加数字按键。gridItemAttr()是自定义的GridItem扩展属性,这样写是为了方便定位GridItem。
private numbers: string[] = ['7', '8', '9', '4', '5', '6', '1', '2', '3'];
private colorBlue: string = CalculatorConstants.THEME_COLOR;

//每个按键的onClick事件都调用这个方法,对表达式作修改
toExpression(options: {backspace?: boolean, clear?: boolean, addition?: string}) {...}

build() {
    Grid() {
    ...
      //数字区
      ForEach(this.numbers, (element: string, index: number) => {
        GridItem() {
          TextButton({ text: element, onBtnClick: () => this.toExpression({ addition: element }) })
        }
        .gridItemAttr(Math.floor(index / 3) + 1, Math.floor(index / 3) + 1, index - Math.floor(index / 3) * 3, index - Math.floor(index / 3) * 3) //使用根据按键布局规律得出的表达式来定位
      })
    }...
}

@Extend(GridItem) function gridItemAttr (rs: number, re: number, cs: number, ce: number) {
  .rowStart(rs)
  .rowEnd(re)
  .columnStart(cs)
  .columnEnd(ce)
}
3.6.2.5 添加其他功能按键。
//清除
GridItem() {
    TextButton({ text: 'C', fontColor: this.colorBlue, onBtnClick: () => this.toExpression({ clear: true }) })
}.gridItemAttr(0, 0, 0, 0)
//除法
GridItem() {
    TextButton({ text: '÷', fontColor: this.colorBlue, onBtnClick: () => this.toExpression({ addition: '/' }) })
}.gridItemAttr(0, 0, 1, 1)
//乘法
GridItem() {
    TextButton({ text: '×', fontColor: this.colorBlue, onBtnClick: () => this.toExpression({ addition: '*' }) })
}.gridItemAttr(0, 0, 2, 2)
//退格
GridItem() {
    Button({ type: ButtonType.Circle, stateEffect: true }) {
        Image($r('app.media.ic_keyboard_delete'))...
    }...
    .onClick(() => this.toExpression({ backspace: true }))
}.gridItemAttr(0, 0, 3, 3)
//加法
GridItem() {
    TextButton({ text: '+', fontColor: this.colorBlue, onBtnClick: () => this.toExpression({ addition: '+' }) })
}.gridItemAttr(1, 1, 3, 3)
//减法
GridItem() {
    TextButton({ text: '-', fontColor: this.colorBlue, onBtnClick: () => this.toExpression({ addition: '-' }) })
}.gridItemAttr(2, 2, 3, 3)
//左括号
GridItem() {
    TextButton({ text: '(', fontColor: this.colorBlue, onBtnClick: () => this.toExpression({ addition: '(' }) })
}.gridItemAttr(3, 3, 3, 3)
//右括号
GridItem() {
    TextButton({ text: ')', fontColor: this.colorBlue, onBtnClick: () => this.toExpression({ addition: ')' }) })
}.gridItemAttr(4, 4, 3, 3)
//等号
GridItem() {
  TextButton({text: '=', fontColor: Color.White, btnBackgroundColor: this.colorBlue, onBtnClick: () => {}})
}.gridItemAttr(4, 4, 0, 0)
//0
GridItem() {
  TextButton({ text: '0', onBtnClick: () => this.toExpression({ addition: '0' }) })
}.gridItemAttr(4, 4, 1, 1)
//小数点
GridItem() {
  TextButton({ text: '.', onBtnClick: () => this.toExpression({ addition: '.' }) })
}.gridItemAttr(4, 4, 2, 2)
3.6.2.6 使用TextArea作为显示表达式的组件,定义@State修饰的expression变量绑定TextArea的显示内容。
@Component
export struct Calculator {
  ...
  [@State](/user/State) expression: string = '0';
  
  build() {
    Column() {
      TextArea({ text: this.expression })...
      Grid() {...}...
    }...
  }
}
3.6.2.7 定义计算表达式的方法。首先调用ExpressionUtil的calculateExpression()方法得到表达式结果,再对结果保留两位小数处理,最后赋值给consumption变量。ExpressionUtil是我自己写的一个计算表达式的工具类,这里不作代码展示和说明,已上传至gitee仓库开源,查看仓库
@Component
export struct Calculator {
  ...
  [@State](/user/State) expression: string = '0';
  [@Consume](/user/Consume)('consumption') consumption: number;
  ...
  private calculate() {
    let result = ExpressionUtil.calculateExpression(this.expression);
    //对计算结果保留两位小数
    let resultStr = result.toString();
    let dotIndex = resultStr.indexOf(Operator.Dot); //-1表示无小数点
    if (dotIndex == -1 || resultStr.length - 1 - dotIndex <= 2) { //如果计算结果是整数或小数位数小于等于2,直接显示
      this.expression = result.toString();
    } else { //小数位数大于2,保留2位
      this.expression = result.toFixed(2);
    }
    this.consumption = parseFloat(this.expression);
    this.toExpression({clear: true});
  }
}
3.6.3 自定义标题栏,实现账单保存
3.6.3.1 在AddPage.ets中定义标题栏。
@Component
struct TitleBar {
  [@Consume](/user/Consume)('bookSelection') bookSelection: AccountBook;
  [@Consume](/user/Consume)('accountSelection') accountSelection: Account;
  [@Consume](/user/Consume)('typeSelection') typeSelection: BillType;
  [@Consume](/user/Consume)('consumption') consumption: number;
  [@Consume](/user/Consume)('isSpend') isSpend: boolean;
  [@Consume](/user/Consume)('remark') remark: string;

  saveBill() {
    if(this.consumption <= 0) {
      prompt.showToast({message: '账单金额必须大于0'})
      return;
    }
    let bill: Bill = {
      id: 0,
      consumption: this.consumption,
      time: 0,
      type: this.typeSelection,
      remark: this.remark=='' ? '没有备注': this.remark,
      spend: this.isSpend,
      accountBookId: this.bookSelection.id,
      accountId: this.accountSelection.id
    }
    ...//保存到AppStorage
    prompt.showToast({message: '已保存账单'})
    this.consumption = 0;
    this.remark = '';
  }

  build() {
    Row() {
      Text('记账')...
      Blank()
      ImageButton({ image: $r('app.media.ic_public_ok'), onButtonClick: () => this.saveBill() })
    }...
  }
}
3.6.4 整合标题栏、计算器和账单编辑组件,完成账单添加页面
@Component
export struct AddPage {
  [@Provide](/user/Provide)('accountBooks') accountBooks: AccountBook[] = ...;
  [@Provide](/user/Provide)('accounts') accounts: Account[] =...;
  [@Provide](/user/Provide)('billTypes') billTypes: BillType[] =...;
  //账单信息
  [@Provide](/user/Provide)('bookSelection') bookSelection: AccountBook = ...;
  [@Provide](/user/Provide)('accountSelection') accountSelection: Account = this.accounts[0];
  [@Provide](/user/Provide)('typeSelection') typeSelection: BillType = this.billTypes[0];
  [@Provide](/user/Provide)('consumption') consumption: number = 0;
  [@Provide](/user/Provide)('isSpend') isSpend: boolean = true;
  [@Provide](/user/Provide)('remark') remark: string = '';

  build() {
    Column() {
      TitleBar()
      BillEditor()
      Blank()
      Calculator()
    }...
  }
}
3.6.5 实现账单添加后,账单页面数据同步刷新
3.6.5.1 在Main组件中添加@Provide修饰的变量billsChange。
@Entry
@Component
struct Main {
  [@Provide](/user/Provide)('billsChangeListener') billsChange: number = 0;
  ...
}
3.6.5.2 在BillPage组件中绑定billsChangeListener。
@Component
export struct BillPage {
  ...
  [@Consume](/user/Consume)('billsChangeListener') [@Watch](/user/Watch)('onBillsChange') billsChange: number;
  ..
  onBillsChange() {
      //获取最新账单数据
    this.bills = ...;
  }
}
3.6.5.3 在AddPage.ets的TitleBar组件中绑定billsChangeListener。在saveBill()方法中完成账单添加后,使变量自增,这时就会触发BillPage中的onBillsChange()方法,回到BillPage页面后就可以看到最新添加的账单。
@Component
struct TitleBar {
  ...
  [@Consume](/user/Consume)('billsChangeListener') billsChange: number;

  saveBill() {
    ...
    this.billsChange++;
    ...
  }
3.6.5.4 这样,整个记账App就基本完成啦。

4 总结

经过这次的不到3天的开发体验,我算是体会到用ArkTS开发HarmonyOS应用能有如此高的效率,并且ArkTS提供的组件、API也都十分方便好用,让我感受到了声明式UI开发的乐趣所在。这个记账应用我打算接着做下去,继续添加管理账本、账户、账单等更多功能,以及HarmonyOS的一大特色即“一次开发,多端部署”的能力,在数据管理方面,我会继续学习关系型数据库和分布式数据库的使用,以便后续在数据层使用,其他细节方面我也会根据自己的想法不断调整,最后让它变成一个真正可用、好用、美观的记账App。

最后,如果此帖能对一些同为HarmonyOS应用开发的初学者提供一些学习和参考的价值,那么我会非常高兴的!


更多关于HarmonyOS 鸿蒙Next 基于ArkTS和Stage模型,我开发了记账应用的实战教程也可以访问 https://www.itying.com/category-93-b0.html

11 回复

请问有无源码??

更多关于HarmonyOS 鸿蒙Next 基于ArkTS和Stage模型,我开发了记账应用的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


楼主,请问一下这个项目开源吗?我想参考一下

不得不说,这个是很棒的应用,我之前用过另一款安卓记账应用,很UI很漂亮。此作品还有很大的优化空间,加油,

小白过来的,入门级的tabs会用了,在TabContent()里面布局填充数据等等都能弄了,但是代码多了查看修改起来头都大了,于是小白意识到一个问题,就是不把一部分分开写的话,以后修改起来很麻烦,搜索进阶教程也没看到,可能对于安卓或者什么其他的程序员转过来的应该是常识性的问题吧!但是小白看了楼主的应用级还是没明白是怎么把其他的ets加进去TabContent()的,于是学习进程就卡住了,如果楼主看见了,麻烦请教一下,TabContent()中是怎么加入ets的?同时也可以作为‘选一个选项卡变一个TabContent()背景颜色’的进阶教程,楼主麻烦了…

我猜你第一个问题可能问的是关于如何细分组件并将它们组合到页面中吧,你可以将一个tab页面中的内容封装成一个组件(用@Component来修饰),然后直接将这个自定义的组件写进TabContent中就行了。就像这个记账app一样,我第一个Tab页面是用来显示跟账单相关的内容的,就把这些内容封装成一个组件叫做BillPage,这个组件可以写在跟主页面同一个文件中,也可以另建一个ets文件来写(最好),之后呢,我在主页面的Tabs组件中的TabContent里面加入这个BillPage组件就行了。第二个Tab页面是显示账户的,那就再建一个AccountPage组件,用同样的方式写在TabContent里面。然后你的第二个问题我也没怎么看懂,是说怎样选中一个Tab标签,就让它的图标颜色发生变化吗?

感谢回答,写代码把自己绕晕了,洗脑去了,想弄一个QQ、微信、支付宝那种框架的,内容可以按照自己的需要填充,只不过tabs要套很多层,感觉很绕,我的思路是如果可以一个控制编辑这个app页面的的控制的另一个程序,但是算来算去就和DevEco Studio不是类似了吗?,谢谢楼主提供的方法,我会尝试写一下,争取把想要的效果做出来…,

牛p,佩服死了,好几个月了,愣是没弄出来,我不想当伸手党,但真的头疼,谁能做一个电话本的软件

感谢分享,对我们初学者很有帮助

不到3天。。。感觉我像个废物

我丢,我整了快一个月了,,,,,还没出来

使用ArkTS和Stage模型开发鸿蒙记账应用时,如何高效管理应用状态?

在鸿蒙系统中,利用ArkTS和Stage模型开发记账应用时,高效管理应用状态的关键在于充分利用ArkTS的声明式编程特性以及Stage模型的生命周期管理。你可以通过ArkTS的组件状态管理功能,轻松跟踪和更新记账应用中的各类数据状态,如账户余额、收支记录等。同时,结合Stage模型提供的页面生命周期回调,你可以在适当的时机执行状态保存与恢复操作,确保应用在切换或重启时能够维持用户数据的一致性。

具体来说,你可以在ArkTS组件中定义状态变量,并通过绑定机制将这些变量与UI元素相关联。当状态变量发生变化时,UI将自动更新以反映最新状态。此外,利用Stage模型的生命周期方法,如onPageShowonPageHide,你可以在页面显示和隐藏时执行相应的状态管理操作。

如果问题依旧没法解决请联系官网客服,官网地址是:https://www.itying.com/category-93-b0.html

回到顶部