鸿蒙零基础

【鸿蒙零基础】登录页+记账流水列表,新手一天踩了5个坑(含完整解决过程)

大家好,我是开始学习鸿蒙的新手小白。今天的目标是用 ArkTS + ArkUI(状态管理 V2 写法) 做一个「随手记」App:登录页 → 账本流水列表页,数据先用 Mock 写死。

本文全程记录真实实操过程,包括我今天踩的 5 个报错 以及修复对比,零基础的同学可以直接跟读复现。文末有源码思路和明日计划,建议先收藏再看 👇
在这里插入图片描述


📊 今日学习档案

项目内容
学习主题登录表单 + 路由传参 + List 列表渲染
开发工具DevEco Studio / HarmonyOS NEXT(API 12)
代码写法ArkUI 状态管理 V2(@ComponentV2 / @Local)
完成结果登录校验跳转 ✅ 20 条收支流水列表 ✅ 收支红绿配色 ✅

✅ 今日学习内容清单

  1. @ComponentV2 + @Local:V2 状态装饰器的基本用法
  2. TextInput 输入框双向绑定与表单非空校验
  3. 仓库模式(Repo):把账号校验逻辑抽到 LoginRepo
  4. router.pushUrl 页面跳转 + 携带参数router.getParams() 接收参数
  5. List + ForEach 渲染长列表,以及键生成器的作用
  6. @Builder 抽取复用的列表行 UI
  7. 颜色资源 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.pngic_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.pngic_income.png 放入 media 目录,重新运行即可。

📝 四、今日学习小结

今天最大的收获是把一个最常见的 App 闭环走通了:

输入账号密码 → 非空校验 → Repo 校验 → router 带参跳转 → 目标页取参 → List 渲染 Mock 数据

几个必须记牢的点:

  1. V2 组件用 @Local,别再写 @State
  2. 路由传参传的是,不是类名,接收端用 interface 断言更稳;
  3. ForEach 永远带上唯一键生成器;
  4. 颜色、图片资源遵循「先注册/放入目录,再 $r() 引用」;
  5. UI 逻辑和数据校验分层(Repo 模式),后期换接口最省事。

老实说报错 2 卡了我快 40 分钟,后来一行行对比才发现把 user.name 写成了 User。新手期的 bug 大多都是这种"手抖 + 概念没分清",把参数类型写出来(interface 断言)真的能提前挡掉很多问题


📅 五、明日学习计划

  • 给列表页加顶部统计区:本月收入 / 支出合计(遍历 records 用 reduce 思想求和)
  • 增加筛选 Tab:全部 / 支出 / 收入
  • 学习 router整个对象(而不只是字符串),为后面的详情页做准备
  • 把 Toast 单引号小瑕疵修掉(之前提示文案不小心多打了一个 '

🌈 以上就是 全部学习记录。如果这篇日记对你有帮助,欢迎 点赞 👍 + 收藏 ⭐ + 关注,我会持续日更鸿蒙零基础学习系列,下一篇见!

有任何报错欢迎评论区贴出你的报错截图,我们一起排查~
在这里插入图片描述

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐