前言

在 HarmonyOS NEXT 正式商用的背景下,ArkTS 作为鸿蒙生态的官方应用开发语言,正在从传统的命令式 UI 开发范式向声明式 UI 范式全面迁移。ArkUI 框架以组件化、声明式、状态驱动为核心特征,要求开发者以"描述 UI 应该是什么样"而非"一步步操作 UI 怎么变"的思维方式构建界面。本文以一个名为"智能账单"的三标签页记账应用为切入点,逐层拆解其数据模型设计、@Builder 组件复用策略、@State 状态管理机制、条件渲染导航模式,以及静态写死渲染与动态循环渲染之间的性能权衡,为 ArkTS 初学者与进阶开发者提供一套可复用的工程实践范式。

在这里插入图片描述

一、应用架构总览:入口组件与标签页分发

整个应用以 @Entry @Component struct BillAppEntry 作为唯一入口页面。入口组件承担三项职责:渲染标题栏 titleBar()、渲染底部标签栏 tabBar()、以及根据 selectedTabIndex 状态分发三个内容页。

@Entry
@Component
struct BillAppEntry {
  @State selectedTabIndex: number = 0

  @Builder TabContentSwitch() {
    Column() {
      if (this.selectedTabIndex === 0) {
        BillTabContent()
      } else if (this.selectedTabIndex === 1) {
        BudgetTabContent()
      } else {
        StatTabContent()
      }
    }
    .layoutWeight(1)
  }

  build() {
    Column() {
      this.titleBar()
      this.tabBar()
      this.TabContentSwitch()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFF8F0F5')
  }
}

入口组件将"导航状态"与"页面渲染"解耦:selectedTabIndex 只负责记录当前选中项,TabContentSwitch 通过条件渲染将状态映射到具体页面。这种"状态→UI 映射"的单向数据流,是声明式 UI 区别于命令式 UI 的根本特征——开发者不再手动 hide/show 某个节点,而是让框架根据状态自动重建对应子树。当 selectedTabIndex 从 0 变为 1 时,框架销毁 BillTabContent 子树、构建 BudgetTabContent 子树,整个过程对开发者透明。

三个内容页 BillTabContentBudgetTabContentStatTabContent 均为独立 @Component,各自维护自己的局部状态(如筛选弹窗的显隐标记),彼此之间通过入口组件的 selectedTabIndex 实现切换,互不耦合。这种设计让每个标签页成为可独立测试、可独立演进的单元。

二、数据模型:@Observed 装饰类与可观察状态

应用定义了两个核心数据类 BillBudget,均使用 @Observed 装饰器标记,并通过构造函数完成字段初始化。

@Observed
export class Bill {
  id: string = ''
  type: 'income' | 'expense' = 'expense'
  amount: number = 0
  category: string = ''
  description: string = ''
  date: string = ''
  constructor(id: string, type: 'income' | 'expense', amount: number, category: string, description: string, date: string) {
    this.id = id
    this.type = type
    this.amount = amount
    this.category = category
    this.description = description
    this.date = date
  }
}

@Observed
export class Budget {
  id: string = ''
  category: string = ''
  limit: number = 0
  spent: number = 0
  constructor(id: string, category: string, limit: number, spent: number) {
    this.id = id
    this.category = category
    this.limit = limit
    this.spent = spent
  }
}

在这里插入图片描述

@Observed 装饰器使得类的属性具备被深度观测的能力。当该类的实例被 @State、@ObjectLink 或 @Prop 等状态变量持有,且属性发生变更时,ArkUI 框架能够精确感知变化并触发依赖该属性的 UI 组件刷新。相比于普通 class,@Observed class 是嵌套对象响应式更新的前提——框架会为被观察实例建立属性代理,拦截赋值操作并广播变更。在本例中,虽然数据以静态 mock 形式写死,但 @Observed 标记保留了后续接入真实数据源(如从 Preferences 或关系型数据库读取)时的响应式能力,是一种面向演进的设计预留,避免未来切换数据源时回过头重构类定义。

两个类的字段设计体现了记账领域的核心实体:Bill 区分收入与支出(type 联合类型)、记录金额与分类;Budget 记录分类预算的限额与已用额度。联合类型 'income' | 'expense' 在编译期约束了取值空间,比使用字符串或数字枚举更具类型安全性。

在这里插入图片描述

二(续)、响应式状态链:@Observed 与 @State 的协作机制

在本应用中,BillBudget 虽然被 @Observed 标记,但其实例目前以模块级 const mockBills / mockBudgets 形式存在,并未被任何 @State 变量持有。这一细节揭示了 ArkTS 响应式系统的前提条件:单独标记 @Observed 并不会自动触发刷新,必须同时满足"被状态变量持有"与"属性被 UI 读取"两个条件,框架才会在属性变更时重建依赖组件。

// 响应式生效的完整链路(生产环境形态,本应用未启用)
@Entry
@Component
struct BillAppEntry {
  @State bills: Bill[] = mockBills  // @State 持有 @Observed 实例
  build() {
    ForEach(this.bills, (b: Bill) => {
      Text(b.amount.toString())  // UI 读取 b.amount,建立依赖
    })
  }
}
// 当执行 this.bills[0].amount = 99 时,框架感知变更并刷新对应 Text

ArkTS 的响应式并非"全局魔法",而是一条精确的依赖追踪链:@State 标记组件级状态变量,@Observed 标记可被深度观测的类,当 @State 持有 @Observed 实例且 UI 组件读取了该实例的某属性,框架会在该属性被重新赋值时只刷新依赖它的子树。本应用之所以选择静态写死、暂不接入这条链路,是因为原型阶段数据不变更,建立响应式链只会增加无谓的开销。理解这条链的存在与触发条件,是后续将 demo 升级为真实应用的关键认知——届时只需把 mock 数组提升为 @State,再把账单列表改为 ForEach 读取,响应式便自动生效,无需改动 Bill 类本身。

设计令牌的集中管理进一步体现了这一思路。:颜色与 emoji 的集中常量管理

应用将分类对应的颜色与 emoji 抽象为模块级 const 对象,作为统一的设计令牌(Design Token),避免散落在各处的硬编码字符串。

const CATEGORY_COLORS: Record<string, string> = {
  '餐饮': '#FF5722',
  '交通': '#2196F3',
  '购物': '#9C27B0',
  '娱乐': '#FF9800',
  '工资': '#4CAF50',
  '其他': '#607D8B'
}
const CATEGORY_EMOJI: Record<string, string> = {
  '餐饮': '🍚',
  '交通': '🚇',
  '购物': '🛒',
  '娱乐': '🎮',
  '工资': '💰',
  '其他': '📦'
}
const SORT_OPTIONS: string[] = ['按日期倒序', '按金额降序', '按类别分组']

在这里插入图片描述

将视觉令牌集中为常量对象,符合"单一来源"原则。当产品需要调整"餐饮"分类的主题色时,只需修改 CATEGORY_COLORS 中一处,所有引用该分类的 UI(账单项图标底色、饼图色块、分类明细条)同步生效,无需逐个搜索替换。Record<string, string> 类型注解为键值访问提供了编译期约束,访问未定义键时 TypeScript 会给出明确的类型错误而非运行时 undefined。这种设计在主题切换、暗色模式适配等场景中价值尤为突出。

四、@Builder:组件复用与参数化构建

本应用大量使用 @Builder 装饰器定义可复用的 UI 片段。@Builder 方法分为无参(如 titleBar()tabBar()barChartStatic())与有参(如 summaryItem(title, val, color, prefix)billStaticItem(emoji, cateColor, desc, date, sign, money))两种形态。

@Builder
summaryItem(title: string, val: string, color: string, prefix: string) {
  Column() {
    Text(title).fontSize(12).fontColor("#888888")
    Text(prefix + " " + val).fontSize(16).fontWeight(FontWeight.Bold).fontColor(color)
  }
  .layoutWeight(1)
  .padding({ top: 14, bottom: 14 })
  .backgroundColor("#FFFFFF")
  .borderRadius(12)
  .margin({ left: 4, right: 4 })
}

@Builder 的本质是"轻量级组件工厂"。与 @Component 相比,@Builder 不具备独立的状态作用域与生命周期,它只是将一段 UI 描述封装为可调用的方法,从而避免重复书写结构相同的 UI 代码。对于"收入/支出/结余"这类需要多次渲染、仅参数不同的卡片,使用参数为 @Builder 付费压低了代码冗余度,同时保证了一致的视觉规范。当参数数量较多时(如 billStaticItem 有 6 个参数),建议进一步抽象为 @Component 以利用结构化的属性声明与独立的 @State 作用域,避免 @Builder 参数过长导致的可读性下降。

有参 @Builder 的调用语法为 this.builderName(arg1, arg2),参数按声明顺序传入。在账单列表场景中,billStaticItem 被四次调用,分别传入不同分类的 emoji、颜色、描述与金额,实现了"一处定义、四处复用"。

this.billStaticItem("🍚", "#FF5722", "早餐-永和大王", "2026-07-17 餐饮", "-", "35.50")
this.billStaticItem("🍚", "#FF5722", "午餐-兰州拉面", "2026-07-17 餐饮", "-", "68.00")
this.billStaticItem("🚇", "#2196F3", "地铁通勤", "2026-07-17 交通", "-", "12.00")
this.billStaticItem("🛒", "#9C27B0", "小米充电宝", "2026-07-16 购物", "-", "299.00")

五、标签页导航:条件渲染与点击状态切换

底部标签栏由三个等宽的 Column 组成,每个 Column 内包含一个 Text 与一条 Divider 下划线指示器。选中态通过 selectedTabIndex 与索引比较动态计算字体粗细与颜色。

Column() {
  Text('账单')
    .fontSize(15)
    .fontWeight(this.selectedTabIndex === 0 ? FontWeight.Bold : FontWeight.Normal)
    .fontColor(this.selectedTabIndex === 0 ? '#FFC107' : '#888888')
    .padding({ top: 12, bottom: 10 })
  Divider()
    .color(this.selectedTabIndex === 0 ? '#FFC107' : Color.Transparent)
    .strokeWidth(3)
    .width('100%')
}
.layoutWeight(1)
.onClick(() => { this.selectedTabIndex = 0 })

选中态的视觉反馈完全由状态派生(selectedTabIndex === N),而非手动切换样式类。点击回调只做一件事:修改 selectedTabIndex 的值。ArkUI 框架监听到 @State 变化后,自动重新执行依赖该状态的 UI 描述(包括 TabContentSwitch 的条件分支与 tabBar 的颜色计算)。这种"状态唯一真相源"模式消除了手动同步 UI 状态与数据状态的不一致风险——开发者永远不需要担心"点了标签但高亮没更新"这类命令式开发中常见的 bug。

三个标签的 onClick 各自将 selectedTabIndex 设为 0、1、2,配合 TabContentSwitch 中的 if-else 链,形成了完整的导航闭环。Divider 的颜色在选中时显示主题黄、未选中时透明,仅通过一条属性绑定即实现了指示器显隐,无需额外管理显隐状态。

五(续)、筛选浮层的状态机设计

账单页的 filterBar() 展示了一种典型的"双弹窗互斥"状态机:日期筛选浮层 datePop 与排序筛选浮层 sortPopdateFilterShowsortFilterShow 两个布尔状态控制,且二者互斥——打开其一会自动关闭另一。

// 点击日期筛选:打开日期浮层,同时关闭排序浮层
.onClick(() => { this.dateFilterShow = !this.dateFilterShow; this.sortFilterShow = false })
// 点击排序筛选:打开排序浮层,同时关闭日期浮层
.onClick(() => { this.sortFilterShow = !this.sortFilterShow; this.dateFilterShow = false })
// 点击遮罩:两者皆关闭
.onClick(() => { this.dateFilterShow = false; this.sortFilterShow = false })

互斥布尔状态机的价值在于消除"两个浮层同时弹出"的中间态。若仅用单一 showFilter: ‘date’ | ‘sort’ | ‘none’ 枚举也能实现互斥,但分离的布尔量在 UI 表达上更直接——每个浮层的显隐只依赖自己的状态变量,条件渲染 if (this.dateFilterShow) 一目了然。点击遮罩统一关闭两者,保证了"点哪关哪"的交互直觉。这种模式在筛选、下拉菜单、级联选择等场景中复用率极高,是声明式 UI 处理临时浮层的推荐范式。

日期与排序浮层内部的每个选项(全部/今日/本周/本月,或三种排序方式)均为独立的 Text,点击后将对应的选中文本写入 selectDateText / selectSortText 并关闭浮层。选中项通过字体颜色与底色高亮(如本周项使用 #FFC107 文字 + #FFF8E1 底色),形成视觉反馈闭环。

六、声明式布局体系:Column/Row/Stack/Grid/Flex 的协同

应用综合运用了 ArkUI 的五大基础布局容器,各自承担清晰的职责边界:

  • Column:纵向线性布局,用于页面主骨架与卡片内部垂直排列,是应用最频繁的容器。
  • Row:横向线性布局,用于摘要行、列表项、标签栏等需要水平排布的场景。
  • Stack:层叠布局,用于筛选弹窗浮层(datePop/sortPop)覆盖在半透明遮罩之上。
  • Grid:网格布局,用于统计页的六宫格指标卡,通过 columnsTemplate("1fr 1fr 1fr")rowsTemplate("1fr 1fr") 定义行列比例。
  • Flex:弹性布局,用于饼图图例的自动换行(Flex({ wrap: FlexWrap.Wrap }))。
Stack() {
  Column()
    .width("100%")
    .height("100%")
    .backgroundColor("rgba(0,0,0,0.1)")
    .onClick(() => { this.dateFilterShow = false; this.sortFilterShow = false })
  if (this.dateFilterShow) {
    this.datePop()
  }
  if (this.sortFilterShow) {
    this.sortPop()
  }
}
.width("100%")
.height(220)

Stack 在这里实现了"模态筛选浮层":底层是半透明遮罩(点击关闭弹窗),上层是条件渲染的弹窗内容。位置通过 .position({ x: 16, y: 8 }).zIndex(99) 固定。这种"遮罩+条件内容"的组合是 ArkUI 中实现轻量弹窗的惯用法,比引入独立的自定义弹窗组件(CustomDialog)更轻、更内聚,适合简单的筛选/选择场景。两个弹窗的显隐由 dateFilterShow 与 sortFilterShow 两个布尔状态控制,且点击遮罩会同时关闭两者,逻辑清晰且无状态残留。

Grid 布局在统计页用于呈现六项关键指标,其声明方式体现了声明式网格的简洁性:

Grid() {
  GridItem() { this.statCardBlock("本月支出", getFixedMonthlyExp().toFixed(2), "#F44336", "支") }
  GridItem() { this.statCardBlock("本周支出", getFixedWeekExp().toFixed(2), "#FF9800", "周") }
  GridItem() { this.statCardBlock("最高分类", "购物", "#9C27B0", "类") }
  GridItem() { this.statCardBlock("日均支出", getFixedDailyAvg().toFixed(2), "#2196F3", "日") }
  GridItem() { this.statCardBlock("储蓄率", getFixedSaveRate().toFixed(1) + "%", "#4CAF50", "储") }
  GridItem() { this.statCardBlock("消费天数", "4天", "#607D8B", "天") }
}
.columnsTemplate("1fr 1fr 1fr")
.rowsTemplate("1fr 1fr")
.columnsGap(8)
.rowsGap(8)
.height(220)

columnsTemplate 与 rowsTemplate 使用 fr 单位描述弹性比例,“1fr 1fr 1fr” 表示三列等宽,“1fr 1fr” 表示两行等高。columnsGap/rowsGap 控制网格间距。Grid 自动将六个 GridItem 按三列两行排列,开发者无需手动计算坐标或嵌套 Row/Column。当指标数量变化(如新增"连续记账天数")时,只需追加 GridItem,模板自动重排,布局逻辑与数据数量解耦。

六(续)、静态图表的像素级构建技巧

账单页的 barChartStatic() 与统计页的 pieStaticBar() 展示了在不引入图表库的前提下,如何用基础布局容器手绘柱状图与饼图。柱状图由七个等宽 Column 组成,每根柱子的高度以硬编码像素值(80、40、0、0…)直接表达数值大小。

Row() {
  Column() {
    Column().width(28).height(80).backgroundColor("#FFC107").borderRadius({ topLeft: 4, topRight: 4 })
    Text("07-17").fontSize(10).fontColor("#888888").margin({ top: 4 })
  }
  .layoutWeight(1)
  Column() {
    Column().width(28).height(40).backgroundColor("#FFC107").borderRadius({ topLeft: 4, topRight: 4 })
    Text("07-16").fontSize(10).fontColor("#888888").margin({ top: 4 })
  }
  .layoutWeight(1)
  // 其余五天高度均为 0,表示无支出
}
.width("100%").height(110).alignItems(VerticalAlign.Bottom)

柱状图的核心技巧是用"定高色块 + 底部对齐"模拟数据条:外层 Row 设 alignItems(VerticalAlign.Bottom),使每根柱子从底部升起;柱高用像素值近似映射数值(最高日 115.5 对应 80px,其余按比例缩放到 40 或 0)。这种手绘方式在数据点极少、且追求零依赖时是合理的——它避免引入 ECharts 等重型图表库,包体积与首帧都更优。代价是高度值需手工推算、无法自适应数据范围;生产环境应改为根据最大值动态计算 height = (value / max) * chartHeight。饼图同理:用三个等宽 Column 拼接一条圆角色带(18% + 3% + 79%)模拟占比,下方 Flex 换行排列图例,是一种轻量但精确的视觉近似。

在这里插入图片描述

七、静态渲染策略:写死 vs 循环的性能与可维护性权衡

本应用最显著的特征是全静态写死:账单列表、预算进度条、统计图表全部以字面量形式直接书写,没有使用任何 ForEach、没有循环遍历。所有汇总数值(总收入、总支出、结余、各类占比)也由一组全局纯函数 getFixedXxx() 直接返回硬编码常量。

function getFixedTotalIncome(): number { return 414.5 }
function getFixedTotalExpense(): number { return 414.5 }
function getFixedBalance(): number { return 0 }
function getFixedMaxDaily(): number { return 115.5 }
function getFixedTotalBudget(): number { return 3500 }
function getFixedTotalSpent(): number { return 1019 }
function getFixedTotalPercent(): number { return 29.11 }

与之对比,动态渲染版本会写成:

// 动态版本(本应用未采用)
ForEach(this.bills, (bill: Bill) => {
  this.billStaticItem(
    CATEGORY_EMOJI[bill.category],
    CATEGORY_COLORS[bill.category],
    bill.description,
    bill.date,
    "-",
    bill.amount.toFixed(2)
  )
})

静态写死策略在此场景下具备明确的工程合理性:其一,应用处于原型验证阶段,数据结构与数据量固定,循环渲染的收益为零;其二,ArkUI 的 ForEach 需要为每个节点建立键值管理与差分更新机制,在节点数量极少(4 条账单)时,直接写死的渲染成本更低、首帧更快;其三,静态写法让 UI 结构与视觉结果一一对应,便于设计师与开发者对齐像素级细节,也便于截图走查。但代价是可维护性差——新增一条账单需手动复制一段 billStaticItem 调用,且数值汇总需手工同步,极易产生数据不一致。因此该策略仅适用于演示与固定模板阶段,生产环境应回归 ForEach + 数据源驱动,让 UI 自动跟随数据变化。

全局纯函数 getFixedXxx() 的设计同样出于原型阶段的取舍:将汇总值从计算逻辑中抽离为常量返回,省去了对 mock 数组的 reduce/map 遍历。这在数据固定时减少了构建期与运行期的计算负担,但失去了"数据变化→汇总自动更新"的能力。一旦接入真实数据源,这些函数应改写为基于 @State 数组的计算属性(getter)或 @Watch 监听回调。

八、效率对比:静态渲染与动态渲染的多维度差异

下表从开发效率、运行时性能、可维护性三个维度对比两种渲染策略在本应用场景下的表现:

维度 静态写死(本应用采用) 动态 ForEach 渲染 差异来源
首帧渲染耗时 更低(无键值 diff 建立开销) 略高(需初始化 ForEach 管理器与键值表) 节点少时静态占优,节点多时差距收窄
代码行数 更高(每条数据重复书写 UI 片段) 更低(单模板复用全部数据) 模板复用消除冗余
新增数据成本 高(手动复制 UI 片段并同步数值) 低(仅向数据源追加一条记录) 数据驱动彻底解耦 UI 与数据
适配真实数据源 需重构渲染层与汇总逻辑 直接替换 mock 数组即可 架构耦合度不同导致迁移成本不同
类型安全 弱(字面量拼接易拼错、易不一致) 强(编译器校验模板参数与数据源字段) 参数化与类型系统提升安全边界
内存占用 基本持平 略高(持有迭代状态与键值映射) 差异在百节点级才显著,原型期可忽略

表中"差异来源"一列揭示了权衡的本质:静态写死的优势集中在"数据量小、结构固定"的原型期,其代价集中在"数据演进"的生产期。选择哪种策略不应孤立判断,而应与项目所处阶段对齐——原型期优先速度,生产期优先可维护性。本应用明确地选择了原型期策略,是一种清醒的工程取舍而非技术债的无意识累积。

九、工程启示与最佳实践总结

通过对本应用的逐层拆解,可提炼出以下 ArkTS 声明式开发的核心实践:

  1. 状态最小化原则:仅将真正驱动 UI 变化的数据标记为 @State(如 selectedTabIndex、dateFilterShow、sortFilterShow),纯展示数据无需进入状态系统,避免不必要的刷新范围扩大。
  2. @Builder 适合轻复用,@Component 适合重封装:当 UI 片段参数超过 4 个或需要独立局部状态时,应升级为 @Component,利用其结构化的属性声明与独立的 @State 作用域。
  3. 条件渲染优于显隐切换:用 if/else 让框架在有条件地重建子树,而非用 visibility 属性手动控制显隐,避免不可见图节点占用构建与布局成本。
  4. 静态写死是原型利器,不是生产答案:在数据结构未稳定、数据量极小时可加速验证;一旦进入迭代期必须切换到数据驱动,否则维护成本指数级上升。
  5. @Observed 是面向未来的预留:即便当前数据写死,标记 @Observed 让后续接入响应式数据源时无需重构类定义,是低成本的架构弹性投资。
  6. 设计令牌集中管理:颜色、emoji、排序选项等跨组件共享的视觉与配置常量应提升为模块级 const,确保单一来源与一致的主题表达。

十、从原型到生产的演进路径

本应用当前是一份典型的"可运行原型":数据写死、汇总硬编码、图表手绘。要将它演进为可上线的真实应用,需要沿三条主线重构,且每条主线都建立在已有架构的弹性预留之上。

第一条主线是数据驱动化。mockBills / mockBudgets 提升为入口组件的 @State 数组,账单列表从四处 billStaticItem 调用改为单个 ForEach(this.bills, ...)。由于 Bill/Budget 已标记 @Observed,这一步无需改动类定义,响应式链自动接通。新增账单只需向数组 push 一条记录,UI 自行刷新。

第二条主线是汇总计算化。getFixedXxx() 系列函数改写为基于 @State 数组的计算逻辑,例如总收入通过 this.bills.filter(b => b.type === 'income').reduce((s, b) => s + b.amount, 0) 实时得出。更优的做法是为入口组件定义 getter 属性(如 get totalIncome()),让模板直接绑定 this.totalIncome.toFixed(2),彻底消除手工同步数值的出错可能。

第三条主线是图表参数化。 柱状图与饼图的硬编码像素高度,应改为由数据最大值动态推算:height = (value / maxValue) * chartHeight。这样图表能自适应任意数据范围,无需每次手动调整色块高度。

这三条主线之所以成本低,根源在于原型阶段的关键决策:用 @Observed 标记数据类、用 @Builder 封装列表项、用条件渲染而非显隐切换。这些决策让"写死"与"数据驱动"之间只差一层封装替换,而非推倒重来。反过来说,若原型期贪图省事把数据直接内联进 Text 字符串、把列表写死为不可参数化的裸组件,生产化时就要逐行拆解,重构成本会成倍放大。原型的速度红利,只有在架构预留了演进接口时才能真正兑现。

结尾

本文以一个三标签页智能账单应用为样本,系统梳理了 HarmonyOS ArkTS 声明式 UI 开发中的架构分层、状态管理、@Builder 复用、条件渲染导航与静态/动态渲染权衡。代码虽以原型写死形式呈现,却完整涵盖了 ArkUI 工程化的核心思想:以状态为单一真相源、以声明描述替代命令操作、以组件复用对抗重复、以设计令牌统一视觉语言。掌握这些范式,开发者便能在从 demo 到生产的演进路径上,平滑地将写死逻辑重构为数据驱动的健壮应用,在原型速度与生产可维护性之间找到恰当的平衡点。这份样本的价值,正在于它用最少的元素暴露了最多的范式,值得作为 ArkTS 入门与架构评审的参照基准。

一、整体架构总览表

先通过表格梳理代码分层、职责、核心特性

代码分层模块 包含内容 核心作用 关键技术点 设计特点
类型定义层 CategoryStat、DailyTotal、Bill、Budget interface/class 定义全局数据结构、实体模型 interface类型约束、@Observed可观察类、构造函数初始化 数据模型与页面UI完全解耦,仅做类型规范,未实际动态渲染使用
静态常量层 CATEGORY_COLORS、CATEGORY_EMOJI、SORT_OPTIONS、mockBills、mockBudgets 分类配色、图标、下拉选项、模拟账单/预算硬编码数据 Record字典、数组常量、new实例静态数组 数据写死,无法新增/删除,仅作为类型参考,页面未循环读取数组
纯工具函数层 getFixedXXX系列固定数值函数 统一提供页面展示静态数字 无参纯函数,return固定数字 完全抛弃动态求和/统计逻辑,全部手写固定结果,无计算逻辑
主入口页面 BillAppEntry(@Entry) 根页面、顶部标题栏、Tab导航、多页面切换容器 @Entry、@State、@Builder布局拆分、Tab切换状态管理 三Tab:账单/预算/统计,使用if判断切换页面,无Tabs原生组件
账单Tab页面 BillTabContent 账单筛选、月度汇总、静态柱状图、账单明细列表 弹窗下拉筛选、Stack遮罩、静态手写柱状图、单条账单Builder 所有图表、列表全部手写重复UI,无循环渲染数组
预算Tab页面 BudgetTabContent 总预算进度条、单分类预算进度卡片 双层进度条Stack布局、剩余金额判断文字变色 每个分类单独手写Builder调用,无循环遍历mockBudgets
统计Tab页面 StatTabContent 6宫格统计卡片、分类占比静态分段条形图、分类明细 Grid网格布局、静态模拟饼图分段条、占比明细行 占比宽度、百分比文字全部硬编码,不通过账单数据计算

二、分段代码逐块深度解析

模块1:TS类型接口与数据实体类定义

interface CategoryStat {
  cat: string
  amount: number
  percent: number
  color: string
}
interface DailyTotal {
  date: string
  income: number
  expense: number
}

@Observed
export class Bill {
  id: string = ''
  type: 'income' | 'expense' = 'expense'
  amount: number = 0
  category: string = ''
  description: string = ''
  date: string = ''
  constructor(id: string, type: 'income' | 'expense', amount: number, category: string, description: string, date: string) {
    this.id = id
    this.type = type
    this.amount = amount
    this.category = category
    this.description = description
    this.date = date
  }
}

@Observed
export class Budget {
  id: string = ''
  category: string = ''
  limit: number = 0
  spent: number = 0
  constructor(id: string, category: string, limit: number, spent: number) {
    this.id = id
    this.limit = limit
    this.spent = spent
  }
}
详细解释
  1. 两个interface 业务数据结构
    • CategoryStat:分类统计模型,理论用于存放每个消费分类名称、总金额、占比、图表颜色,但本项目全程未使用该接口,仅预留类型;
    • DailyTotal:日收支模型,存储日期、当日收入、当日支出,同样预留未调用;
  2. @Observed 可观察实体类 Bill 账单类
    • 装饰器@Observed:ArkUI响应式装饰器,类属性变更后自动刷新绑定该类的UI;
    • 字段含义:
      Bill字段 含义 默认值
      id 账单唯一标识 空字符串
      type 收支类型,联合类型仅允许income收入/expense支出 expense支出
      amount 账单金额 0
      category 消费分类(餐饮/交通/购物等) 空字符串
      description 消费备注描述 空字符串
      date 账单日期字符串 YYYY-MM-DD 空字符串
    • 构造函数:实例化时一次性传入所有字段赋值,规范创建账单对象;
  3. @Observed Budget 预算类
    • 用于存储单分类预算数据:分类id、分类名称、预算上限limit、已花费金额spent;
    • 同样支持响应式更新,理论修改spent后页面进度条自动刷新,但本项目数据静态写死,无修改逻辑;
  4. 设计缺陷
    虽然定义了标准实体模型,但页面渲染完全没有读取mockBills/mockBudgets数组做循环渲染,实体类仅用于构造静态模拟数据,数据和UI完全割裂。

模块2:全局静态常量 & Mock模拟数据

const CATEGORY_COLORS: Record<string, string> = {
  '餐饮': '#FF5722',
  '交通': '#2196F3',
  '购物': '#9C27B0',
  '娱乐': '#FF9800',
  '工资': '#4CAF50',
  '其他': '#607D8B'
}
const CATEGORY_EMOJI: Record<string, string> = {
  '餐饮': '🍚',
  '交通': '🚇',
  '购物': '🛒',
  '娱乐': '🎮',
  '工资': '💰',
  '其他': '📦'
}
const SORT_OPTIONS: string[] = ['按日期倒序', '按金额降序', '按类别分组']

// 静态写死数据 不做循环遍历
const mockBills: Bill[] = [
  new Bill('b1', 'expense', 35.5, '餐饮', '早餐-永和大王', '2026-07-17'),
  new Bill('b2', 'expense', 68.0, '餐饮', '午餐-兰州拉面', '2026-07-17'),
  new Bill('b3', 'expense', 12.0, '交通', '地铁通勤', '2026-07-17'),
  new Bill('b4', 'expense', 299.0, '购物', '小米充电宝', '2026-07-16'),
]
const mockBudgets: Budget[] = [
  new Budget('bud1', '餐饮', 2000, 370.5),
  new Budget('bud2', '交通', 500, 110.5),
  new Budget('bud3', '购物', 1000, 538.0),
]
详细解释
  1. Record字典常量(分类配色、图标)
    • Record<string, string> TS工具类型,代表键是字符串分类名,值是颜色/Emoji图标;
    • 作用:统一管理全App分类视觉规范,避免硬编码颜色分散在各个组件;
    • 现状:页面没有通过category字段去字典匹配取值,而是直接手写颜色、emoji,常量完全闲置;
  2. SORT_OPTIONS 排序下拉数组
    存储账单筛选的三种排序文案,同样没有循环生成下拉选项,弹窗文字全部手写,数组未使用;
  3. mock模拟数组说明
    • mockBills:4条固定支出账单,无收入账单;
    • mockBudgets:3个分类月度预算,包含预算总额、已消费金额;
    • 核心问题:代码注释标注“不做循环遍历”,刻意放弃数组遍历能力,页面明细、预算卡片全部重复手写UI,数组仅作为静态数据备份,无业务读取逻辑;
  4. 拓展优化方向:正常项目会ForEach(mockBills, item => 渲染账单),自动适配新增账单,本代码完全舍弃动态渲染。

模块3:全局固定数值工具函数

function getFixedTotalIncome(): number {
  return 414.5
}
function getFixedTotalExpense(): number {
  return 414.5
}
function getFixedBalance(): number {
  return 0
}
function getFixedMaxDaily(): number {
  return 115.5
}
function getFixedTotalBudget(): number {
  return 3500
}
function getFixedTotalSpent(): number {
  return 1019
}
function getFixedTotalPercent(): number {
  return 29.11
}
function getFixedMonthlyExp(): number {
  return 414.5
}
function getFixedWeekExp(): number {
  return 115.5
}
function getFixedDailyAvg(): number {
  return 103.63
}
function getFixedSaveRate(): number {
  return 0
}
详细解释
  1. 函数设计目的:统一抽离页面所有统计数字,页面直接调用函数获取数值,统一管理;
  2. 底层逻辑缺陷:所有函数无任何计算逻辑,直接return手写死数字;
    • 理论正常逻辑:读取mockBills数组,遍历累加收支、计算占比、日均、预算消耗百分比;
    • 当前逻辑:人工提前算好数值写死,修改账单数据后,所有函数返回值不会同步更新,数据完全脱节;
  3. 各函数业务含义对照表
    函数名 返回固定值 业务含义
    getFixedTotalIncome 414.5 本月总收入
    getFixedTotalExpense 414.5 本月总支出
    getFixedBalance 0 当月收支结余
    getFixedMaxDaily 115.5 单日最高消费
    getFixedTotalBudget 3500 所有分类预算总和
    getFixedTotalSpent 1019 所有分类已花费总和
    getFixedTotalPercent 29.11 总预算消耗占比
    getFixedMonthlyExp 414.5 月度总支出
    getFixedWeekExp 115.5 本周总支出
    getFixedDailyAvg 103.63 日均消费金额
    getFixedSaveRate 0 储蓄率(无收入所以为0)

模块4:@Entry 根页面 BillAppEntry 全局容器

@Entry
@Component
struct BillAppEntry {
  @State selectedTabIndex: number = 0

  @Builder TabContentSwitch() {
    Column() {
      if (this.selectedTabIndex === 0) {
        BillTabContent()
      } else if (this.selectedTabIndex === 1) {
        BudgetTabContent()
      } else {
        StatTabContent()
      }
    }
    .layoutWeight(1)
  }

  build() {
    Column() {
      this.titleBar()
      this.tabBar()
      this.TabContentSwitch()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFF8F0F5')
  }

  @Builder
  titleBar() {
    Column() {
      Text('智能账单')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 16, bottom: 12 })
    }
    .width('100%')
    .backgroundColor('#FFC107')
  }

  @Builder
  tabBar() {
    Row() {
      // 账单Tab按钮
      Column() {
        Text('账单')
          .fontSize(15)
          .fontWeight(this.selectedTabIndex === 0 ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.selectedTabIndex === 0 ? '#FFC107' : '#888888')
          .padding({ top: 12, bottom: 10 })
        Divider()
          .color(this.selectedTabIndex === 0 ? '#FFC107' : Color.Transparent)
          .strokeWidth(3)
          .width('100%')
      }
      .layoutWeight(1)
      .onClick(() => { this.selectedTabIndex = 0 })
      // 预算Tab按钮
      Column() {
        Text('预算')
          .fontSize(15)
          .fontWeight(this.selectedTabIndex === 1 ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.selectedTabIndex === 1 ? '#FFC107' : '#888888')
          .padding({ top: 12, bottom: 10 })
        Divider()
          .color(this.selectedTabIndex === 1 ? '#FFC107' : Color.Transparent)
          .strokeWidth(3)
          .width('100%')
      }
      .layoutWeight(1)
      .onClick(() => { this.selectedTabIndex = 1 })
      // 统计Tab按钮
      Column() {
        Text('统计')
          .fontSize(15)
          .fontWeight(this.selectedTabIndex === 2 ? FontWeight.Bold : FontWeight.Normal)
        Divider()
          .color(this.selectedTabIndex === 2 ? '#FFC107' : Color.Transparent)
      }
      .layoutWeight(1)
      .onClick(() => { this.selectedTabIndex = 2 })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
  }
}
详细解释
  1. 核心响应式状态 @State selectedTabIndex: number = 0
    • @State:ArkUI页面内响应式装饰器,变量变更自动刷新UI;
    • 0=账单页、1=预算页、2=统计页,默认打开账单页面;
  2. 页面整体布局结构(build顶层Column垂直布局)
    从上至下三层固定结构:
    1. titleBar:顶部黄色全局标题栏;
    2. tabBar:三栏Tab切换导航栏;
    3. TabContentSwitch:页面主体内容区,layoutWeight(1)自动占满剩余屏幕高度;
  3. @Builder复用UI片段规范
    • 将标题栏、Tab栏、页面切换容器抽为独立Builder,拆分build函数,代码分层清晰;
    • Tab切换逻辑:纯if判断渲染对应子页面,未使用ArkUI原生Tabs内置组件,完全手动封装Tab;
  4. Tab导航交互逻辑
    • 三列Row平分宽度layoutWeight(1)
    • 点击Column触发onClick修改selectedTabIndex;
    • 激活态样式:文字加粗、黄色字体、底部黄色分割线;未激活灰色文字、透明分割线;
  5. 优缺点
    • 优点:手动Tab自定义程度极高,样式自由控制;
    • 缺点:三个Tab按钮重复代码极多,无循环复用,新增Tab需要复制一大段代码;原生Tabs组件可大幅简化代码。

模块5:BillTabContent 账单页面(核心静态UI页)

代码过长,拆分核心功能块讲解

5.1 页面状态定义(筛选弹窗控制)
@Component
struct BillTabContent {
  @State dateFilterShow: boolean = false
  @State sortFilterShow: boolean = false
  @State selectDateText: string = "本周"
  @State selectSortText: string = "按日期倒序"
  • @State四个响应变量:控制日期筛选弹窗、排序弹窗显示隐藏,保存当前选中筛选文案;
  • 弹窗互斥逻辑:点击日期筛选自动关闭排序弹窗,反之亦然,避免双层弹窗重叠。
5.2 下拉弹窗Builder datePop / sortPop
  • Stack遮罩实现弹窗:底层半透明黑色遮罩,点击遮罩任意位置关闭弹窗;
  • 弹窗内每个选项独立手写Text,点击赋值筛选文字并关闭弹窗;
  • 选中项高亮黄色文字+浅黄色背景,未选中灰色白底;
  • 缺陷:筛选仅修改页面展示文字,无真实数据过滤逻辑,账单列表永远显示4条固定数据,筛选功能纯视觉效果。
5.3 filterBar 筛选栏组件

Row横向放置两个标签按钮:日期筛选、排序筛选;点击切换弹窗显隐,Stack承载弹窗浮层,zIndex提升层级保证弹窗在页面上层。

5.4 summaryItem 收支汇总卡片Builder

三栏平分卡片,分别展示总收入、总支出、结余,传入标题、数值、文字颜色、正负前缀,复用样式减少重复代码;数值全部通过固定工具函数获取。

5.5 barChartStatic 静态柱状图(重点硬编码设计)
@Builder
barChartStatic() {
  Column() {
    Row() {
      Text("每日支出(近7天)").fontSize(13).fontColor("#555555")
      Blank()
      Text("最高 115.50/天").fontSize(11).fontColor("#999999")
    }
    Row() {
      Column() { Column().width(28).height(80).backgroundColor("#FFC107") Text("07-17") }
      Column() { Column().width(28).height(40).backgroundColor("#FFC107") Text("07-16") }
      // 其余5天高度写死0,无消费
    }
    .alignItems(VerticalAlign.Bottom)
  }
}
  • 实现原理:底部对齐垂直布局,每个日期对应固定高度黄色矩形;
  • 07-17高度80(当日消费最高115.5),07-16高度40,其余日期高度0;
  • 致命缺陷:高度完全手写,新增账单、修改单日金额不会自动调整柱子高度,无数据映射关系,纯静态图片式UI。
5.6 billStaticItem 单条账单Builder + 手写明细列表
this.billStaticItem("🍚", "#FF5722", "早餐-永和大王", "2026-07-17 餐饮", "-", "35.50")
this.billStaticItem("🍚", "#FF5722", "午餐-兰州拉面", "2026-07-17 餐饮", "-", "68.00")
this.billStaticItem("🚇", "#2196F3", "地铁通勤", "2026-07-17 交通", "-", "12.00")
this.billStaticItem("🛒", "#9C27B0", "小米充电宝", "2026-07-16 购物", "-", "299.00")
  • Builder封装单条账单通用布局:左侧圆形Emoji色块、中间备注日期、右侧红色支出金额;
  • 但页面没有循环遍历mockBills数组,4条账单完全重复手写调用Builder,新增账单必须手动复制一行代码,无法动态渲染。

模块6:BudgetTabContent 预算页面

核心两个Builder:totalProgress全局总预算进度条、singleBudgetBar单分类预算卡片

  1. totalProgress:
    • 双层Stack进度条:底层灰色背景条,上层黄色进度条宽度写死getFixedTotalPercent() + "%"
    • 展示总已花费/总预算数字;
  2. singleBudgetBar 单分类进度卡片
    • 入参包含分类名、图标、占比字符串、进度宽度数值、是否超预算标记、颜色、剩余文字、已用/总额;
    • 进度条颜色:超预算自动变红,正常为分类对应色;剩余金额文字同步变色;
    • 页面调用三次Builder分别对应餐饮/交通/购物,无循环读取mockBudgets,新增分类需要手动新增一行调用代码;
  3. 数值全部手写固定,修改Budget的spent已花费金额后,进度条、占比文字不会同步更新。

模块7:StatTabContent 统计页面

  1. statCardBlock + Grid六宫格统计卡片
    Grid网格3列2行,6个固定统计项:本月支出、本周支出、最高分类、日均支出、储蓄率、消费天数;所有数值硬编码,无自动统计逻辑;
  2. pieStaticBar 静态模拟饼图(分段横向色块)
    Row内置三段固定宽度彩色列:餐饮18%、交通3%、购物79%,宽度百分比手写,搭配图例文字;无法根据账单金额自动计算占比宽度;
  3. cateDetailItem 分类占比明细行
    三条明细手动书写购物、餐饮、交通,百分比、金额全部人工计算写死,无动态求和逻辑。

安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

interface CategoryStat {
  cat: string
  amount: number
  percent: number
  color: string
}
interface DailyTotal {
  date: string
  income: number
  expense: number
}

@Observed
export class Bill {
  id: string = ''
  type: 'income' | 'expense' = 'expense'
  amount: number = 0
  category: string = ''
  description: string = ''
  date: string = ''
  constructor(id: string, type: 'income' | 'expense', amount: number, category: string, description: string, date: string) {
    this.id = id
    this.type = type
    this.amount = amount
    this.category = category
    this.description = description
    this.date = date
  }
}

@Observed
export class Budget {
  id: string = ''
  category: string = ''
  limit: number = 0
  spent: number = 0
  constructor(id: string, category: string, limit: number, spent: number) {
    this.id = id
    this.category = category
    this.limit = limit
    this.spent = spent
  }
}

// 静态常量 无循环计算
const CATEGORY_COLORS: Record<string, string> = {
  '餐饮': '#FF5722',
  '交通': '#2196F3',
  '购物': '#9C27B0',
  '娱乐': '#FF9800',
  '工资': '#4CAF50',
  '其他': '#607D8B'
}
const CATEGORY_EMOJI: Record<string, string> = {
  '餐饮': '🍚',
  '交通': '🚇',
  '购物': '🛒',
  '娱乐': '🎮',
  '工资': '💰',
  '其他': '📦'
}
const SORT_OPTIONS: string[] = ['按日期倒序', '按金额降序', '按类别分组']

// 静态写死数据 不做循环遍历
const mockBills: Bill[] = [
  new Bill('b1', 'expense', 35.5, '餐饮', '早餐-永和大王', '2026-07-17'),
  new Bill('b2', 'expense', 68.0, '餐饮', '午餐-兰州拉面', '2026-07-17'),
  new Bill('b3', 'expense', 12.0, '交通', '地铁通勤', '2026-07-17'),
  new Bill('b4', 'expense', 299.0, '购物', '小米充电宝', '2026-07-16'),
]
const mockBudgets: Budget[] = [
  new Budget('bud1', '餐饮', 2000, 370.5),
  new Budget('bud2', '交通', 500, 110.5),
  new Budget('bud3', '购物', 1000, 538.0),
]

// 全局纯工具函数(仅外部调用,不在Builder内执行)
function getFixedTotalIncome(): number {
  return 414.5
}
function getFixedTotalExpense(): number {
  return 414.5
}
function getFixedBalance(): number {
  return 0
}
function getFixedMaxDaily(): number {
  return 115.5
}
function getFixedTotalBudget(): number {
  return 3500
}
function getFixedTotalSpent(): number {
  return 1019
}
function getFixedTotalPercent(): number {
  return 29.11
}
function getFixedMonthlyExp(): number {
  return 414.5
}
function getFixedWeekExp(): number {
  return 115.5
}
function getFixedDailyAvg(): number {
  return 103.63
}
function getFixedSaveRate(): number {
  return 0
}

// 入口页面
@Entry
@Component
struct BillAppEntry {
  @State selectedTabIndex: number = 0

  @Builder TabContentSwitch() {
    Column() {
      if (this.selectedTabIndex === 0) {
        BillTabContent()
      } else if (this.selectedTabIndex === 1) {
        BudgetTabContent()
      } else {
        StatTabContent()
      }
    }
    .layoutWeight(1)
  }

  build() {
    Column() {
      this.titleBar()
      this.tabBar()
      this.TabContentSwitch()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFF8F0F5')
  }

  @Builder
  titleBar() {
    Column() {
      Text('智能账单')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 16, bottom: 12 })
    }
    .width('100%')
    .backgroundColor('#FFC107')
  }

  @Builder
  tabBar() {
    Row() {
      Column() {
        Text('账单')
          .fontSize(15)
          .fontWeight(this.selectedTabIndex === 0 ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.selectedTabIndex === 0 ? '#FFC107' : '#888888')
          .padding({ top: 12, bottom: 10 })
        Divider()
          .color(this.selectedTabIndex === 0 ? '#FFC107' : Color.Transparent)
          .strokeWidth(3)
          .width('100%')
      }
      .layoutWeight(1)
      .onClick(() => { this.selectedTabIndex = 0 })

      Column() {
        Text('预算')
          .fontSize(15)
          .fontWeight(this.selectedTabIndex === 1 ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.selectedTabIndex === 1 ? '#FFC107' : '#888888')
          .padding({ top: 12, bottom: 10 })
        Divider()
          .color(this.selectedTabIndex === 1 ? '#FFC107' : Color.Transparent)
          .strokeWidth(3)
          .width('100%')
      }
      .layoutWeight(1)
      .onClick(() => { this.selectedTabIndex = 1 })

      Column() {
        Text('统计')
          .fontSize(15)
          .fontWeight(this.selectedTabIndex === 2 ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.selectedTabIndex === 2 ? '#FFC107' : '#888888')
          .padding({ top: 12, bottom: 10 })
        Divider()
          .color(this.selectedTabIndex === 2 ? '#FFC107' : Color.Transparent)
          .strokeWidth(3)
          .width('100%')
      }
      .layoutWeight(1)
      .onClick(() => { this.selectedTabIndex = 2 })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
  }
}

// 账单页面 完全静态写死 无ForEach、无循环
@Component
struct BillTabContent {
  @State dateFilterShow: boolean = false
  @State sortFilterShow: boolean = false
  @State selectDateText: string = "本周"
  @State selectSortText: string = "按日期倒序"

  @Builder
  datePop() {
    Column() {
      Text("全部")
        .fontSize(14)
        .fontColor("#333333")
        .width("100%")
        .padding({ top: 10, bottom: 10, left: 16 })
        .backgroundColor("#FFFFFF")
        .borderRadius(6)
        .onClick(() => { this.selectDateText = "全部"; this.dateFilterShow = false })
      Text("今日")
        .fontSize(14)
        .fontColor("#333333")
        .width("100%")
        .padding({ top: 10, bottom: 10, left: 16 })
        .backgroundColor("#FFFFFF")
        .borderRadius(6)
        .onClick(() => { this.selectDateText = "今日"; this.dateFilterShow = false })
      Text("本周")
        .fontSize(14)
        .fontColor("#FFC107")
        .fontWeight(FontWeight.Bold)
        .width("100%")
        .padding({ top: 10, bottom: 10, left: 16 })
        .backgroundColor("#FFF8E1")
        .borderRadius(6)
        .onClick(() => { this.selectDateText = "本周"; this.dateFilterShow = false })
      Text("本月")
        .fontSize(14)
        .fontColor("#333333")
        .width("100%")
        .padding({ top: 10, bottom: 10, left: 16 })
        .backgroundColor("#FFFFFF")
        .borderRadius(6)
        .onClick(() => { this.selectDateText = "本月"; this.dateFilterShow = false })
    }
    .width("90%")
    .padding(8)
    .backgroundColor("#FFFFFF")
    .borderRadius(12)
    .position({ x: 16, y: 8 })
    .zIndex(99)
  }

  @Builder
  sortPop() {
    Column() {
      Text("按日期倒序")
        .fontSize(14)
        .fontColor("#FFC107")
        .fontWeight(FontWeight.Bold)
        .width("100%")
        .padding({ top: 10, bottom: 10, left: 16 })
        .backgroundColor("#FFF8E1")
        .borderRadius(6)
        .onClick(() => { this.selectSortText = "按日期倒序"; this.sortFilterShow = false })
      Text("按金额降序")
        .fontSize(14)
        .fontColor("#333333")
        .width("100%")
        .padding({ top: 10, bottom: 10, left: 16 })
        .backgroundColor("#FFFFFF")
        .borderRadius(6)
        .onClick(() => { this.selectSortText = "按金额降序"; this.sortFilterShow = false })
      Text("按类别分组")
        .fontSize(14)
        .fontColor("#333333")
        .width("100%")
        .padding({ top: 10, bottom: 10, left: 16 })
        .backgroundColor("#FFFFFF")
        .borderRadius(6)
        .onClick(() => { this.selectSortText = "按类别分组"; this.sortFilterShow = false })
    }
    .width("90%")
    .padding(8)
    .backgroundColor("#FFFFFF")
    .borderRadius(12)
    .position({ x: 16, y: 8 })
    .zIndex(99)
  }

  @Builder
  filterBar() {
    Column() {
      Row() {
        Row() {
          Text(this.selectDateText)
            .fontSize(12)
            .fontColor("#FFC107")
        }
        .padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .backgroundColor("#FFF8E1")
        .borderRadius(14)
        .onClick(() => { this.dateFilterShow = !this.dateFilterShow; this.sortFilterShow = false })
        Blank()
        Row() {
          Text(this.selectSortText)
            .fontSize(12)
            .fontColor("#009688")
        }
        .padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .backgroundColor("#E0F2F1")
        .borderRadius(14)
        .onClick(() => { this.sortFilterShow = !this.sortFilterShow; this.dateFilterShow = false })
      }
      .width("100%")
      .padding({ left: 16, right: 16, top: 4 })

      Stack() {
        Column()
          .width("100%")
          .height("100%")
          .backgroundColor("rgba(0,0,0,0.1)")
          .onClick(() => { this.dateFilterShow = false; this.sortFilterShow = false })
        if (this.dateFilterShow) {
          this.datePop()
        }
        if (this.sortFilterShow) {
          this.sortPop()
        }
      }
      .width("100%")
      .height(220)
    }
    .width("100%")
  }

  @Builder
  summaryItem(title: string, val: string, color: string, prefix: string) {
    Column() {
      Text(title).fontSize(12).fontColor("#888888")
      Text(prefix + " " + val).fontSize(16).fontWeight(FontWeight.Bold).fontColor(color)
    }
    .layoutWeight(1)
    .padding({ top: 14, bottom: 14 })
    .backgroundColor("#FFFFFF")
    .borderRadius(12)
    .margin({ left: 4, right: 4 })
  }

  @Builder
  barChartStatic() {
    Column() {
      Row() {
        Text("每日支出(近7天)").fontSize(13).fontColor("#555555")
        Blank()
        Text("最高 115.50/天").fontSize(11).fontColor("#999999")
      }
      .width("100%")
      .padding({ bottom: 8 })
      Row() {
        Column() {
          Column().width(28).height(80).backgroundColor("#FFC107").borderRadius({ topLeft: 4, topRight: 4 })
          Text("07-17").fontSize(10).fontColor("#888888").margin({ top: 4 })
        }
        .layoutWeight(1)
        Column() {
          Column().width(28).height(40).backgroundColor("#FFC107").borderRadius({ topLeft: 4, topRight: 4 })
          Text("07-16").fontSize(10).fontColor("#888888").margin({ top: 4 })
        }
        .layoutWeight(1)
        Column() {
          Column().width(28).height(0).backgroundColor("#FFC107").borderRadius({ topLeft: 4, topRight: 4 })
          Text("07-15").fontSize(10).fontColor("#888888").margin({ top: 4 })
        }
        .layoutWeight(1)
        Column() {
          Column().width(28).height(0).backgroundColor("#FFC107").borderRadius({ topLeft: 4, topRight: 4 })
          Text("07-14").fontSize(10).fontColor("#888888").margin({ top: 4 })
        }
        .layoutWeight(1)
        Column() {
          Column().width(28).height(0).backgroundColor("#FFC107").borderRadius({ topLeft: 4, topRight: 4 })
          Text("07-13").fontSize(10).fontColor("#888888").margin({ top: 4 })
        }
        .layoutWeight(1)
        Column() {
          Column().width(28).height(0).backgroundColor("#FFC107").borderRadius({ topLeft: 4, topRight: 4 })
          Text("07-12").fontSize(10).fontColor("#888888").margin({ top: 4 })
        }
        .layoutWeight(1)
        Column() {
          Column().width(28).height(0).backgroundColor("#FFC107").borderRadius({ topLeft: 4, topRight: 4 })
          Text("07-11").fontSize(10).fontColor("#888888").margin({ top: 4 })
        }
        .layoutWeight(1)
      }
      .width("100%").height(110).alignItems(VerticalAlign.Bottom)
    }
    .width("100%")
    .padding(12)
    .backgroundColor("#FFFFFF")
    .borderRadius(12)
    .margin({ left: 12, right: 12, top: 8, bottom: 8 })
  }

  @Builder
  billStaticItem(emoji: string, cateColor: string, desc: string, date: string, sign: string, money: string) {
    Row() {
      Column() {
        Text(emoji).fontSize(18)
      }
      .width(40).height(40).backgroundColor(cateColor).borderRadius(10)
      Column() {
        Text(desc).fontSize(14).fontColor("#333333")
        Text(date).fontSize(10).fontColor("#999999").margin({ top: 2 })
      }
      .layoutWeight(1).padding({ left: 10 })
      Text(sign + money).fontSize(15).fontWeight(FontWeight.Bold).fontColor("#F44336")
    }
    .width("100%")
    .padding({ left: 16, right: 16, top: 10, bottom: 10 })
    .backgroundColor("#FFFFFF")
    .borderRadius(10)
    .margin({ left: 12, right: 12, top: 4 })
  }

  build() {
    Scroll() {
      Column() {
        Text("2026年7月").fontSize(18).fontColor("#333333")
          .width("100%").textAlign(TextAlign.Center).padding({ top: 16, bottom: 12 })

        Row() {
          this.summaryItem("总收入", getFixedTotalIncome().toFixed(2), "#4CAF50", "+")
          this.summaryItem("总支出", getFixedTotalExpense().toFixed(2), "#F44336", "-")
          this.summaryItem("结余", getFixedBalance().toFixed(2), "#4CAF50", "=")
        }
        .width("100%")
        .padding({ left: 12, right: 12 })

        this.barChartStatic()
        this.filterBar()

        Row() {
          Text("账单明细").fontSize(16).fontWeight(FontWeight.Bold).fontColor("#333333")
          Blank()
          Text("共 4 条").fontSize(12).fontColor("#999999")
        }
        .width("100%")
        .padding({ left: 16, right: 16, top: 8 })

        this.billStaticItem("🍚", "#FF5722", "早餐-永和大王", "2026-07-17 餐饮", "-", "35.50")
        this.billStaticItem("🍚", "#FF5722", "午餐-兰州拉面", "2026-07-17 餐饮", "-", "68.00")
        this.billStaticItem("🚇", "#2196F3", "地铁通勤", "2026-07-17 交通", "-", "12.00")
        this.billStaticItem("🛒", "#9C27B0", "小米充电宝", "2026-07-16 购物", "-", "299.00")
      }
      .width("100%").padding({ bottom: 20 })
    }
    .width("100%").layoutWeight(1).scrollBar(BarState.Off)
  }
}

// 预算页面 全静态无循环
@Component
struct BudgetTabContent {
  @Builder
  singleBudgetBar(cateName: string, emoji: string, pctStr: string, pctNum: number, isOver: boolean, color: string, remainText: string, usedStr: string, totalStr: string) {
    Column() {
      Row() {
        Text(emoji).fontSize(16)
        Text(cateName).fontSize(14).fontColor("#333333").margin({ left: 8 })
        Blank()
        Text(pctStr).fontSize(14).fontWeight(FontWeight.Bold).fontColor(isOver ? "#F44336" : color)
      }
      .width("100%").padding({ left: 16, right: 16, top: 12 })
      Stack() {
        Row()
          .width("100%")
          .height(6)
          .backgroundColor("#EEEEEE")
          .borderRadius(3)
        Row()
          .width(pctNum + "%")
          .height(6)
          .backgroundColor(isOver ? "#F44336" : color)
          .borderRadius(3)
      }
      .width("100%").height(6).padding({ left: 16, right: 16 })
      Row() {
        Text(remainText).fontSize(11).fontColor(isOver ? "#F44336" : "#4CAF50")
        Blank()
        Text("已用 " + usedStr + " / " + totalStr).fontSize(11).fontColor("#888888")
      }
      .width("100%").padding({ left: 16, right: 16, top: 4, bottom: 12 })
    }
    .width("100%")
    .backgroundColor("#FFFFFF")
    .borderRadius(12)
    .margin({ left: 12, right: 12, top: 6 })
  }

  @Builder
  totalProgress() {
    Column() {
      Row() {
        Text("本月总预算").fontSize(14).fontColor("#333333")
        Blank()
        Text(getFixedTotalSpent().toFixed(2) + " / " + getFixedTotalBudget().toFixed(2))
          .fontSize(13).fontColor("#4CAF50").fontWeight(FontWeight.Bold)
      }
      .width("100%").padding({ left: 16, right: 16, top: 16 })
      Stack() {
        Row()
          .width("100%")
          .height(14)
          .backgroundColor("#EEEEEE")
          .borderRadius(7)
        Row()
          .width(getFixedTotalPercent() + "%")
          .height(14)
          .backgroundColor("#FFC107")
          .borderRadius(7)
      }
      .width("100%").padding({ left: 16, right: 16 })
      Row() {
        Text(getFixedTotalSpent().toFixed(2)).fontSize(11).fontColor("#666666")
        Blank()
        Text(getFixedTotalBudget().toFixed(2)).fontSize(11).fontColor("#999999")
      }
      .width("100%").padding({ left: 16, right: 16, top: 6, bottom: 14 })
    }
    .width("100%")
    .backgroundColor("#FFFFFF")
    .borderRadius(16)
    .margin({ left: 12, right: 12, top: 12 })
  }

  build() {
    Scroll() {
      Column() {
        this.totalProgress()
        Row() {
          Text("分类预算").fontSize(16).fontWeight(FontWeight.Bold).fontColor("#333333")
          Blank()
          Text("已用 " + getFixedTotalSpent().toFixed(2) + " / " + getFixedTotalBudget().toFixed(2)).fontSize(12).fontColor("#888888")
        }
        .width("100%").padding({ left: 16, right: 16, top: 20 })

        this.singleBudgetBar("餐饮", "🍚", "18.53%", 18.53, false, "#FF5722", "剩余 1629.50", "370.50", "2000")
        this.singleBudgetBar("交通", "🚇", "22.10%", 22.10, false, "#2196F3", "剩余 389.50", "110.50", "500")
        this.singleBudgetBar("购物", "🛒", "53.80%", 53.80, false, "#9C27B0", "剩余 462.00", "538.00", "1000")

        Text("剩余预算总额: 2470.50 元")
          .fontSize(11).fontColor("#BBBBBB")
          .width("100%").textAlign(TextAlign.Center).padding({ top: 16 })
      }
      .width("100%").padding({ bottom: 20 })
    }
    .width("100%").layoutWeight(1).scrollBar(BarState.Off)
  }
}

// 统计页面 全静态写死 无任何循环ForEach
@Component
struct StatTabContent {
  @Builder
  statCardBlock(title: string, val: string, color: string, prefix: string) {
    Column() {
      Text(prefix).fontSize(20).fontColor(color)
      Text(val).fontSize(16).fontWeight(FontWeight.Bold).fontColor(color)
      Text(title).fontSize(11).fontColor("#888888")
    }
    .width("100%")
    .height(100)
    .backgroundColor("#FFFFFF")
    .borderRadius(14)
    .justifyContent(FlexAlign.Center)
  }

  @Builder
  pieStaticBar() {
    Column() {
      Row() {
        Column().width("18%").height(36).backgroundColor("#FF5722")
        Column().width("3%").height(36).backgroundColor("#2196F3")
        Column().width("79%").height(36).backgroundColor("#9C27B0")
      }
      .width("100%").height(36).clip(true).borderRadius(8)
      Flex({ wrap: FlexWrap.Wrap }) {
        Row() {
          Column().width(10).height(10).borderRadius(3).backgroundColor("#FF5722")
          Text("餐饮 103.5").fontSize(11).margin({ left: 4 })
        }
        .margin({ right: 14 })
        Row() {
          Column().width(10).height(10).borderRadius(3).backgroundColor("#2196F3")
          Text("交通 12").fontSize(11).margin({ left: 4 })
        }
        .margin({ right: 14 })
        Row() {
          Column().width(10).height(10).borderRadius(3).backgroundColor("#9C27B0")
          Text("购物 299").fontSize(11).margin({ left: 4 })
        }
      }
      .width("100%").padding({ left: 16, right: 16, top: 8 })
    }
    .width("100%")
    .padding({ left: 12, right: 12 })
    .backgroundColor("#FFFFFF")
    .borderRadius(12)
  }

  @Builder
  cateDetailItem(name: string, pct: string, money: string, color: string) {
    Row() {
      Text(name).fontSize(13).fontColor("#333333")
      Blank()
      Column() {
        Text(pct).fontSize(12).fontColor(color).fontWeight(FontWeight.Bold)
        Text(money).fontSize(11).fontColor("#888888").margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width("100%")
    .padding({ left: 16, right: 16, top: 10, bottom: 10 })
    .backgroundColor("#FFFFFF")
    .borderRadius(10)
    .margin({ left: 12, right: 12, top: 4 })
  }

  build() {
    Scroll() {
      Column() {
        Text("支出统计").fontSize(16).fontWeight(FontWeight.Bold).fontColor("#333333")
          .width("100%").textAlign(TextAlign.Center).padding({ top: 16, bottom: 12 })
        Grid() {
          GridItem() { this.statCardBlock("本月支出", getFixedMonthlyExp().toFixed(2), "#F44336", "支") }
          GridItem() { this.statCardBlock("本周支出", getFixedWeekExp().toFixed(2), "#FF9800", "周") }
          GridItem() { this.statCardBlock("最高分类", "购物", "#9C27B0", "类") }
          GridItem() { this.statCardBlock("日均支出", getFixedDailyAvg().toFixed(2), "#2196F3", "日") }
          GridItem() { this.statCardBlock("储蓄率", getFixedSaveRate().toFixed(1) + "%", "#4CAF50", "储") }
          GridItem() { this.statCardBlock("消费天数", "4天", "#607D8B", "天") }
        }
        .columnsTemplate("1fr 1fr 1fr")
        .rowsTemplate("1fr 1fr")
        .width("100%")
        .padding({ left: 12, right: 12 })
        .columnsGap(8)
        .rowsGap(8)
        .height(220)

        Text("分类占比").fontSize(14).fontColor("#333333")
          .width("100%").textAlign(TextAlign.Start).padding({ left: 16, top: 20, bottom: 10 })
        this.pieStaticBar()

        this.cateDetailItem("购物", "72.12%", "299.00", "#9C27B0")
        this.cateDetailItem("餐饮", "24.97%", "103.50", "#FF5722")
        this.cateDetailItem("交通", "2.89%", "12.00", "#2196F3")
      }
      .width("100%").padding({ bottom: 20 })
    }
    .width("100%").layoutWeight(1).scrollBar(BarState.Off)
  }
}

三、代码整体优缺点汇总表

优势 缺陷/待优化点
1. 代码分层清晰,使用@Builder复用通用UI卡片、列表项、进度条,减少样式重复;
2. 统一管理分类颜色、Emoji常量,视觉规范集中;
3. 响应式状态管理规范,弹窗、Tab切换使用@State标准ArkUI写法;
4. 所有页面采用Scroll滚动容器,适配超长内容;
5. UI完整:账单、预算、统计三大模块功能页面齐全,交互弹窗、进度图表、列表布局完整;
6. 实体类使用@Observed规范定义,具备拓展真实数据存储的基础
1. 全程禁用数组循环渲染,mockBills/mockBudgets数组完全闲置,新增数据必须手动复制UI代码,无动态列表;
2. 所有统计数值、图表宽度、占比百分比全部硬编码手写,无数据计算逻辑,修改模拟数据后页面不会同步刷新;
3. 筛选功能仅视觉交互,无真实数据过滤、排序逻辑;
4. Tab切换、下拉选项、分类预算卡片大量重复代码,无循环简化;
5. 预留的CategoryStat、DailyTotal接口完全未使用,数据模型浪费;
6. 无新增账单、编辑预算、删除数据等业务操作逻辑,仅纯静态展示Demo;
7. 柱状图、饼图使用原生组件手写模拟,未使用ArkUI图表组件,无动态数据绑定能力

在这里插入图片描述

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐