鸿蒙 ArkTS,登录页 + 记账流水列表,新手踩 5 个坑
鸿蒙零基础
【鸿蒙零基础】登录页+记账流水列表,新手一天踩了5个坑(含完整解决过程)
大家好,我是开始学习鸿蒙的新手小白。今天的目标是用 ArkTS + ArkUI(状态管理 V2 写法) 做一个「随手记」App:登录页 → 账本流水列表页,数据先用 Mock 写死。
本文全程记录真实实操过程,包括我今天踩的 5 个报错 以及修复对比,零基础的同学可以直接跟读复现。文末有源码思路和明日计划,建议先收藏再看 👇
📊 今日学习档案
| 项目 | 内容 |
|---|---|
| 学习主题 | 登录表单 + 路由传参 + List 列表渲染 |
| 开发工具 | DevEco Studio / HarmonyOS NEXT(API 12) |
| 代码写法 | ArkUI 状态管理 V2(@ComponentV2 / @Local) |
| 完成结果 | 登录校验跳转 ✅ 20 条收支流水列表 ✅ 收支红绿配色 ✅ |
✅ 今日学习内容清单
@ComponentV2+@Local:V2 状态装饰器的基本用法TextInput输入框双向绑定与表单非空校验- 仓库模式(Repo):把账号校验逻辑抽到
LoginRepo router.pushUrl页面跳转 + 携带参数、router.getParams()接收参数List + ForEach渲染长列表,以及键生成器的作用@Builder抽取复用的列表行 UI- 颜色资源
color.json、图片资源media的引用方式
📚 一、核心知识点梳理(先理论后动手)
1. V2 写法:@ComponentV2 配 @Local
旧写法(V1)是 @Entry @Component struct + @State;新写法(V2)组件要换成 @ComponentV2,组件内部私有状态用 @Local:
@Entry
@ComponentV2
struct LoginPage {
@Local username: string = '' // 输入的账号
@Local password: string = '' // 输入的密码
}
⚠️ 重点记忆:@ComponentV2 里不能再用 @State,否则编译器直接报错(今天踩的第 1 个坑,后面细讲)。
2. 仓库模式:页面不管数据校验
把"账号密码对不对"这件事从页面里抽出去,放到 model/LoginRepo.ets,页面只负责调用。以后换成真实网络请求时,只改 Repo 不动 UI,这就是分层的好处。
3. 路由跳转与传参
router.pushUrl({
url: 'pages/ListPage',
params: { username: user.name } // 跳转时带上参数
})
router.clear() // 清空路由栈,禁止返回到登录页
列表页在 aboutToAppear() 生命周期里取参数:
aboutToAppear(): void {
const params = router.getParams() as PageParams
this.title = ((params?.username ?? '张同学') as string) + '的账本'
}
4. List + ForEach
长列表一定要用 List(自带懒加载复用),不要用 Column 硬堆。ForEach 第三个参数是键生成器,帮框架识别每一行:
List() {
ForEach(this.records, (item: Record) => {
ItemRecord(item)
}, (item: Record) => item.id.toString()) // 键生成器
}
🔨 二、实操练习全过程
步骤 1:先建数据模型层
在 entry/src/main/ets/model/ 下新建两个文件。
Record.ets —— 一条收支记录:
export class Record {
id: number
type: string // expense 支出 / income 收入
amount: number
note: string
date: string
constructor(id: number, type: string, amount: number, note: string, date: string) {
this.id = id
this.type = type
this.amount = amount
this.note = note
this.date = date
}
}
MockData.ets —— 20 条假数据:
import { Record } from './Record'
export class MockData {
static records(): Record[] {
return [
new Record(1, 'expense', 18.50, '午饭·食堂二楼', '09-05 12:10'),
new Record(2, 'expense', 6.00, '地铁·上下班', '09-05 08:30'),
new Record(3, 'income', 3200.00, '兼职工资', '09-05 10:00'),
// ……共 20 条,支出 income/expense 混排
]
}
}
步骤 2:登录页 LoginPage
输入框通过 onChange 实时把值写回状态变量,点登录走 doLogin():
@Entry
@ComponentV2
struct LoginPage {
@Local username: string = ''
@Local password: string = ''
build() {
Column({ space: 12 }) {
Text("随手记").fontSize(24).fontWeight(FontWeight.Bold)
TextInput({ placeholder: '请输入账号' })
.onChange((value: string) => { this.username = value })
TextInput({ placeholder: '请输入密码' })
.type(InputType.Password)
.onChange((value: string) => { this.password = value })
Button("登录")
.width('100%')
.margin({ top: 24 })
.onClick(() => { this.doLogin() })
}
.width('100%').height('100%').padding(24)
.justifyContent(FlexAlign.Center)
}
}
doLogin() 逻辑:先非空校验 → 再让 Repo 校验账号 → 通过才跳转:
private doLogin(): void {
if (this.username === '' || this.password === '') {
promptAction.showToast({ message: '账号和密码不能为空' })
return
}
const user: User | null = LoginRepo.login(this.username, this.password)
if (user === null) {
promptAction.showToast({ message: '账号或者密码错误' })
return
}
router.pushUrl({
url: 'pages/ListPage',
params: { username: user.name }
})
router.clear()
}
测试账号写死在 LoginRepo 里:student / 123456。
步骤 3:列表页 ListPage
用 List + ForEach 渲染 20 条数据,每一行抽成一个 @Builder:
@Entry
@ComponentV2
struct ListPage {
@Local title: string = ''
@Local records: Record[] = MockData.records()
aboutToAppear(): void {
const params = router.getParams() as PageParams
this.title = ((params?.username ?? '张同学') as string) + '的账本'
}
build() {
Column() {
Text(this.title).fontSize(20).fontWeight(FontWeight.Bold).padding(16)
List() {
ForEach(this.records, (item: Record) => {
ItemRecord(item)
}, (item: Record) => item.id.toString())
}
.width('100%').layoutWeight(1)
}
.width('100%').height('100%')
}
}
// 复用的单行:图标 + 备注日期 + 金额
@Builder
function ItemRecord(item: Record) {
Row() {
Image(item.type === 'expense' ? $r('app.media.ic_expense') : $r('app.media.ic_income'))
.width(40).height(40)
Column() {
Text(item.note).fontSize(16)
Text(item.date).fontSize(12).fontColor(Color.Gray)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })
Text((item.type === 'expense' ? '-' : '+') + item.amount.toFixed(2))
.fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(item.type === 'expense'
? $r('app.color.color_expense')
: $r('app.color.color_income'))
}
.width('100%').padding(16).alignItems(VerticalAlign.Center)
}
步骤 4:补齐颜色和图片资源
颜色要在 resources/base/element/color.json 里先注册,否则 $r() 引用不到:
{ "name": "color_expense", "value": "#E53935" },
{ "name": "color_income", "value": "#43A047" }
把 ic_expense.png、ic_income.png 两张小图标丢进 resources/base/media/。
🐛 三、今日报错问题 + 完整解决方案(重点!)
❌ 报错 1:@ComponentV2 里用了 @State,编译标红
- 现象:照抄旧教程写了
@State username,Build 后装饰器位置直接红线报错。 - 原因:V1 的
@State只能配@Component;V2 组件@ComponentV2的内部状态必须用@Local。 - 解决:
- @Entry @Component
- struct LoginPage {
- @State username: string = ''
+ @Entry @ComponentV2
+ struct LoginPage {
+ @Local username: string = ''
❌ 报错 2:跳转后列表标题显示异常(参数传成了"类")
- 现象:登录成功跳转后,标题不是用户名,出现
undefined/ 一串奇怪的文字。 - 原因:我当时手滑写成了
params: { username: User }——传过去的是类本身而不是登录用户的名字。 - 解决:
LoginRepo.login()返回的是user对象,要取它的.name:
router.pushUrl({
url: 'pages/ListPage',
- params: { username: User }
+ params: { username: user.name }
})
经验:传参前先想清楚你传的是「字符串」还是「对象」,接收方类型也要对上(我在 ListPage 定义了 interface PageParams { username?: string } 做断言)。
❌ 报错 3:ForEach 控制台黄色警告,列表更新行为怪异
- 现象:日志里提示 ForEach 缺少 key generator;改数据时界面刷新不符合预期。
- 原因:
ForEach只写了前两个参数(数据源、UI 生成函数),没写第三个键生成器,框架无法稳定识别每一行。 - 解决:补上第三个参数,且 key 必须唯一:
ForEach(this.records,
(item: Record) => { ItemRecord(item) },
(item: Record) => item.id.toString() // 用唯一 id 作为 key
)
❌ 报错 4:$r('app.color.color_expense') 资源找不到
- 现象:引用自定义颜色时报资源不存在,运行后金额不变色。
- 原因:颜色只在代码里写了名字,没在
color.json注册。 - 解决:在
resources/base/element/color.json中添加color_expense / color_income两个颜色项(见步骤 4),资源必须先声明后引用。
❌ 报错 5:引用图标后图片不显示
- 现象:
$r('app.media.ic_expense')位置空白。 - 排查:① 图片没放进
resources/base/media/;② 文件名带下划线以外的特殊符号 / 大小写不一致;③ 放进去后没重新 Build。 - 解决:规范命名
ic_expense.png、ic_income.png放入 media 目录,重新运行即可。
📝 四、今日学习小结
今天最大的收获是把一个最常见的 App 闭环走通了:
输入账号密码 → 非空校验 → Repo 校验 → router 带参跳转 → 目标页取参 → List 渲染 Mock 数据
几个必须记牢的点:
- V2 组件用
@Local,别再写@State; - 路由传参传的是值,不是类名,接收端用 interface 断言更稳;
ForEach永远带上唯一键生成器;- 颜色、图片资源遵循「先注册/放入目录,再
$r()引用」; - UI 逻辑和数据校验分层(Repo 模式),后期换接口最省事。
老实说报错 2 卡了我快 40 分钟,后来一行行对比才发现把 user.name 写成了 User。新手期的 bug 大多都是这种"手抖 + 概念没分清",把参数类型写出来(interface 断言)真的能提前挡掉很多问题。
📅 五、明日学习计划
- 给列表页加顶部统计区:本月收入 / 支出合计(遍历 records 用 reduce 思想求和)
- 增加筛选 Tab:全部 / 支出 / 收入
- 学习
router传整个对象(而不只是字符串),为后面的详情页做准备 - 把 Toast 单引号小瑕疵修掉(之前提示文案不小心多打了一个
')
🌈 以上就是 全部学习记录。如果这篇日记对你有帮助,欢迎 点赞 👍 + 收藏 ⭐ + 关注,我会持续日更鸿蒙零基础学习系列,下一篇见!
有任何报错欢迎评论区贴出你的报错截图,我们一起排查~
更多推荐




所有评论(0)