HarmonyOS ArkTS API 24实现声明式 UI 框架与 @Builder 组件复用模式构建三标签页智能账单应用
前言
在 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 子树,整个过程对开发者透明。
三个内容页 BillTabContent、BudgetTabContent、StatTabContent 均为独立 @Component,各自维护自己的局部状态(如筛选弹窗的显隐标记),彼此之间通过入口组件的 selectedTabIndex 实现切换,互不耦合。这种设计让每个标签页成为可独立测试、可独立演进的单元。
二、数据模型:@Observed 装饰类与可观察状态
应用定义了两个核心数据类 Bill 与 Budget,均使用 @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 的协作机制
在本应用中,Bill 与 Budget 虽然被 @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 与排序筛选浮层 sortPop 由 dateFilterShow 与 sortFilterShow 两个布尔状态控制,且二者互斥——打开其一会自动关闭另一。
// 点击日期筛选:打开日期浮层,同时关闭排序浮层
.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 声明式开发的核心实践:
- 状态最小化原则:仅将真正驱动 UI 变化的数据标记为 @State(如 selectedTabIndex、dateFilterShow、sortFilterShow),纯展示数据无需进入状态系统,避免不必要的刷新范围扩大。
- @Builder 适合轻复用,@Component 适合重封装:当 UI 片段参数超过 4 个或需要独立局部状态时,应升级为 @Component,利用其结构化的属性声明与独立的 @State 作用域。
- 条件渲染优于显隐切换:用 if/else 让框架在有条件地重建子树,而非用 visibility 属性手动控制显隐,避免不可见图节点占用构建与布局成本。
- 静态写死是原型利器,不是生产答案:在数据结构未稳定、数据量极小时可加速验证;一旦进入迭代期必须切换到数据驱动,否则维护成本指数级上升。
- @Observed 是面向未来的预留:即便当前数据写死,标记 @Observed 让后续接入响应式数据源时无需重构类定义,是低成本的架构弹性投资。
- 设计令牌集中管理:颜色、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
}
}
详细解释
- 两个interface 业务数据结构
CategoryStat:分类统计模型,理论用于存放每个消费分类名称、总金额、占比、图表颜色,但本项目全程未使用该接口,仅预留类型;DailyTotal:日收支模型,存储日期、当日收入、当日支出,同样预留未调用;
- @Observed 可观察实体类 Bill 账单类
- 装饰器
@Observed:ArkUI响应式装饰器,类属性变更后自动刷新绑定该类的UI; - 字段含义:
Bill字段 含义 默认值 id 账单唯一标识 空字符串 type 收支类型,联合类型仅允许income收入/expense支出 expense支出 amount 账单金额 0 category 消费分类(餐饮/交通/购物等) 空字符串 description 消费备注描述 空字符串 date 账单日期字符串 YYYY-MM-DD 空字符串 - 构造函数:实例化时一次性传入所有字段赋值,规范创建账单对象;
- 装饰器
- @Observed Budget 预算类
- 用于存储单分类预算数据:分类id、分类名称、预算上限limit、已花费金额spent;
- 同样支持响应式更新,理论修改spent后页面进度条自动刷新,但本项目数据静态写死,无修改逻辑;
- 设计缺陷
虽然定义了标准实体模型,但页面渲染完全没有读取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),
]
详细解释
- Record字典常量(分类配色、图标)
Record<string, string>TS工具类型,代表键是字符串分类名,值是颜色/Emoji图标;- 作用:统一管理全App分类视觉规范,避免硬编码颜色分散在各个组件;
- 现状:页面没有通过
category字段去字典匹配取值,而是直接手写颜色、emoji,常量完全闲置;
- SORT_OPTIONS 排序下拉数组
存储账单筛选的三种排序文案,同样没有循环生成下拉选项,弹窗文字全部手写,数组未使用; - mock模拟数组说明
- mockBills:4条固定支出账单,无收入账单;
- mockBudgets:3个分类月度预算,包含预算总额、已消费金额;
- 核心问题:代码注释标注“不做循环遍历”,刻意放弃数组遍历能力,页面明细、预算卡片全部重复手写UI,数组仅作为静态数据备份,无业务读取逻辑;
- 拓展优化方向:正常项目会
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
}
详细解释
- 函数设计目的:统一抽离页面所有统计数字,页面直接调用函数获取数值,统一管理;
- 底层逻辑缺陷:所有函数无任何计算逻辑,直接return手写死数字;
- 理论正常逻辑:读取mockBills数组,遍历累加收支、计算占比、日均、预算消耗百分比;
- 当前逻辑:人工提前算好数值写死,修改账单数据后,所有函数返回值不会同步更新,数据完全脱节;
- 各函数业务含义对照表
函数名 返回固定值 业务含义 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')
}
}
详细解释
- 核心响应式状态
@State selectedTabIndex: number = 0- @State:ArkUI页面内响应式装饰器,变量变更自动刷新UI;
- 0=账单页、1=预算页、2=统计页,默认打开账单页面;
- 页面整体布局结构(build顶层Column垂直布局)
从上至下三层固定结构:- titleBar:顶部黄色全局标题栏;
- tabBar:三栏Tab切换导航栏;
- TabContentSwitch:页面主体内容区,
layoutWeight(1)自动占满剩余屏幕高度;
- @Builder复用UI片段规范
- 将标题栏、Tab栏、页面切换容器抽为独立Builder,拆分build函数,代码分层清晰;
- Tab切换逻辑:纯if判断渲染对应子页面,未使用ArkUI原生
Tabs内置组件,完全手动封装Tab;
- Tab导航交互逻辑
- 三列Row平分宽度
layoutWeight(1); - 点击Column触发onClick修改selectedTabIndex;
- 激活态样式:文字加粗、黄色字体、底部黄色分割线;未激活灰色文字、透明分割线;
- 三列Row平分宽度
- 优缺点
- 优点:手动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单分类预算卡片
- totalProgress:
- 双层Stack进度条:底层灰色背景条,上层黄色进度条宽度写死
getFixedTotalPercent() + "%"; - 展示总已花费/总预算数字;
- 双层Stack进度条:底层灰色背景条,上层黄色进度条宽度写死
- singleBudgetBar 单分类进度卡片
- 入参包含分类名、图标、占比字符串、进度宽度数值、是否超预算标记、颜色、剩余文字、已用/总额;
- 进度条颜色:超预算自动变红,正常为分类对应色;剩余金额文字同步变色;
- 页面调用三次Builder分别对应餐饮/交通/购物,无循环读取mockBudgets,新增分类需要手动新增一行调用代码;
- 数值全部手写固定,修改Budget的spent已花费金额后,进度条、占比文字不会同步更新。
模块7:StatTabContent 统计页面
- statCardBlock + Grid六宫格统计卡片
Grid网格3列2行,6个固定统计项:本月支出、本周支出、最高分类、日均支出、储蓄率、消费天数;所有数值硬编码,无自动统计逻辑; - pieStaticBar 静态模拟饼图(分段横向色块)
Row内置三段固定宽度彩色列:餐饮18%、交通3%、购物79%,宽度百分比手写,搭配图例文字;无法根据账单金额自动计算占比宽度; - 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图表组件,无动态数据绑定能力 |

更多推荐



所有评论(0)