在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

写在前面

在数字化生活日益普及的今天,验证码已成为我们日常使用各类应用时频繁接触的安全验证方式——登录微信需要验证码、支付需要验证码、重置密码需要验证码……据不完全统计,一个普通用户每天可能接收到3-8条验证码短信。然而,验证码的「用完即走」特性也带来了一个痛点:我们很难追溯自己的验证码使用历史——某个验证码是否已经使用?什么时候收到的?来自哪个应用?过期了吗?

为了解决这一需求,我们基于 HarmonyOS NEXT(API 24)ArkTS 框架,从零开发了一款「验证码使用记录APP」。这款应用帮助用户记录和管理所有验证码的使用情况,包括查看、筛选、标记状态等功能。

本文将从需求分析、架构设计、UI开发、状态管理、交互实现到构建部署,完整记录这款应用的开发全过程。全文约10000字,适合HarmonyOS初中级开发者阅读学习。

开发背景

选择这个选题有三个原因:第一,验证码管理是一个真实且普遍存在的需求,几乎每一位智能手机用户都能从中获得价值;第二,该应用的功能边界清晰、复杂度适中,非常适合作为 ArkTS 入门到进阶的教学案例;第三,应用虽然体量小,但涵盖了列表渲染、条件渲染、状态管理、弹窗交互、筛选过滤等移动端开发的通用技术点,具有很好的教学示范意义。

技术要点概览

在开发这款应用的过程中,我们涉及了以下关键技术点:

技术领域 具体内容
声明式UI @Builder组件化、@State响应式状态管理
列表渲染 ForEach + key生成器、条件筛选
条件渲染 if语句控制UI显隐、TransitionEffect过渡动画
交互反馈 点击事件、弹窗遮罩、状态变更
API 24适配 ItemAlign、TextOverflowOptions、TransitionEffect
布局系统 Stack层叠、Scroll滚动、Grid网格、Row/Column弹性布局

第一章:需求分析与产品设计

1.1 产品背景

验证码管理是一个被广泛忽视但真实存在的需求。现有的解决方案无非是依赖短信应用的搜索功能,或者完全不管理。然而,以下场景暴露了这一痛点的严重性:

  • 场景一:用户同时收到多个验证码,分不清哪个是最新的、哪个已经用过
  • 场景二:用户想确认某个操作是否已完成,但找不到对应的验证码记录
  • 场景三:验证码过期导致操作失败,用户需要重新申请

基于以上场景,我们定义了一款轻量级的验证码管理工具。

1.2 功能需求

优先级 功能模块 详细描述
P0 记录展示 卡片列表展示所有验证码记录,包括验证码、应用、用途、时间
P0 状态标记 每条记录有三种状态:未使用、已使用、已过期
P0 统计概览 顶部四张卡片展示全部/未使用/已使用/已过期的数量
P1 筛选功能 按状态筛选记录:全部/未使用/已使用/已过期
P1 详情弹窗 点击记录查看完整详情,支持标记状态变更
P1 添加记录 通过底部按钮快速添加新的验证码记录
P2 视觉反馈 不同状态使用不同颜色标识,选中态高亮

1.3 数据模型设计

验证码记录的核心数据结构如下:

interface CodeRecord {
  id: string          // 唯一标识
  code: string        // 验证码内容(6位数字)
  purpose: string     // 用途(登录验证/注册账号/重置密码/支付确认/修改信息)
  source: string      // 来源方式(短信/邮件/语音)
  appName: string     // 应用名称
  time: string        // 接收时间
  date: string        // 接收日期
  expireTime: string  // 过期时间
  status: number      // 状态:0=未使用, 1=已使用, 2=已过期
}

字段设计考量:

  • status 使用 number 而非 enum:方便与筛选索引对齐(0/1/2对应三个状态),减少类型转换
  • datetime 分离:便于按日期分组展示(未来可扩展)
  • purposeappName 分离:同一个应用可能有多种用途(如微信登录验证 vs 微信支付确认)

1.4 状态配色方案

三种状态采用三种语义化颜色,确保用户一目了然:

状态 颜色 色值 语义
未使用 蓝色 #2196F3 待处理,需要关注
已使用 绿色 #4CAF50 已完成,安全可靠
已过期 橙色 #FF9800 已失效,需要重新申请

第二章:应用架构设计

2.1 整体架构

本应用采用「单页面 + 多弹窗」的架构模式——所有功能在一个页面内完成,通过弹窗实现详情查看和新增记录:

Index (@Entry @Component)
├── Stack (根容器)
│   ├── Column (背景层)
│   ├── Column (主内容层)
│   │   ├── Row (标题栏)
│   │   ├── Scroll
│   │   │   ├── @Builder StatsCards (统计卡片)
│   │   │   ├── @Builder FilterTabs (筛选标签)
│   │   │   └── ForEach → @Builder CodeCard (记录卡片 × N)
│   │   └── Row (底部添加按钮)
│   ├── @Builder DetailDialog (详情弹窗, 条件渲染)
│   └── @Builder AddSheet (添加记录弹窗, 条件渲染)

架构选择理由:

选择单页面而非多页面路由,基于以下考量:

  • 应用功能单一,所有操作都在一个视图中完成
  • 弹窗比页面跳转交互更轻量、反馈更直接
  • 避免页面间状态同步的复杂性

2.2 组件树与数据流

                            ┌─────────────────────────┐
                            │       Index 组件          │
                            │  @State records[]        │
                            │  @State activeFilter     │
                            │  @State showDetail       │
                            │  @State detailRecord     │
                            │  @State showAddSheet     │
                            └────────┬────────────────┘
                                     │
              ┌──────────────────────┼──────────────────────┐
              │                      │                      │
              ▼                      ▼                      ▼
      StatsCards()            FilterTabs()           CodeCard(item)
      (读取 records 统计)    (修改 activeFilter)    (读取单个 item)
                                                       │
                                                  onClick → 设置
                                                  detailRecord
                                                       │
                                                       ▼
                                              DetailDialog()
                                              (读取 detailRecord)

这是一个典型的单向数据流架构:

  1. 数据从 @State 流向 UI
  2. 用户交互通过事件回调修改 @State
  3. ArkTS 响应式系统自动更新 UI

2.3 @State 状态管理

本应用使用了 5 个 @State 变量:

@State records: CodeRecord[] = [ /* 10条模拟数据 */ ]
@State activeFilter: number = 0           // 当前筛选:0=全部, 1=未使用, 2=已使用, 3=已过期
@State showDetail: boolean = false        // 详情弹窗显隐
@State detailRecord: CodeRecord | null = null  // 当前查看的记录
@State showAddSheet: boolean = false      // 添加记录弹窗显隐

@State vs private 的选择:

在 ArkTS 中,@Stateprivate 都可以用来声明成员变量,但它们的语义完全不同:

装饰器 响应式 用途
@State 值变化时需要自动更新 UI 的变量
private 数据初始化后不会变化的常量

在本应用中,records 被标记为 @State,因为用户的"标记已使用"和"添加新记录"操作会修改数组内容,需要触发 UI 更新。而 statusLabelsstatusColorsfilterLabels 这些辅助常量则使用 private,因为它们在整个生命周期中保持不变。

注意:在 ArkTS 中,@State 修饰的数组必须通过重新赋值(this.records = [...])或数组变异方法(pushunshiftsplice)来修改,才能触发 UI 更新。直接修改数组元素的属性(如 this.records[0].status = 1)可能不会触发响应式更新。因此我们通过 this.detailRecord!.status = 1 来修改——这是因为 detailRecord 指向的就是 records 数组中的同一个对象引用,修改对象属性后,当 @State records 在下次渲染时被读取,变化会自然体现。

状态变更链路示例(用户点击筛选标签):

用户点击「未使用」标签
    → this.activeFilter = 1
    → filteredRecords() 方法重新计算,返回 status === 0 的记录
    → ForEach 自动更新列表 UI,只显示未使用的记录

状态变更链路示例(用户标记验证码为已使用):

用户在详情弹窗点击「标记为已使用」
    → this.detailRecord!.status = 1
    → showDetail = false
    → records 数组中对应记录的 status 被修改
    → 统计卡片自动重新计算数量
    → 列表中的状态标签从蓝色「未使用」变为绿色「已使用」

第三章:UI 组件开发详解

3.1 统计卡片(StatsCards)

统计卡片是页面的视觉入口,用四张并列的小卡片展示各状态的数量:

@Builder
StatsCards() {
  Row() {
    this.StatItem('📋 全部', this.records.length, '#5C6BC0', '#E8EAF6')
    this.StatItem('🟦 未使用', this.records.filter(r => r.status === 0).length, '#2196F3', '#E3F2FD')
    this.StatItem('🟩 已使用', this.records.filter(r => r.status === 1).length, '#4CAF50', '#E8F5E9')
    this.StatItem('🟧 已过期', this.records.filter(r => r.status === 2).length, '#FF9800', '#FFF3E0')
  }
  .width('100%')
  .padding({ left: 14, right: 14, top: 8 })
  .justifyContent(FlexAlign.SpaceEvenly)
}

每张卡片由 StatItem 构建:

@Builder
StatItem(label: string, count: number, color: string, bgColor: string) {
  Column() {
    Text(count.toString())
      .fontSize(22).fontWeight(FontWeight.Bold).fontColor(color)
    Text(label)
      .fontSize(10).fontColor('#888888').margin({ top: 2 })
      .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
  }
  .width(70)
  .padding({ top: 12, bottom: 10 })
  .backgroundColor(bgColor)
  .borderRadius(12)
  .alignItems(HorizontalAlign.Center)
}

设计要点:

  • 使用 SpaceEvenly 让四张卡片在 Row 中均匀分布
  • 数字使用 22fp 大字 + 粗体 + 状态色,突出数量
  • 标签使用 10fp 小字 + 灰色,弱化辅助信息
  • textOverflow({ overflow: TextOverflow.Ellipsis }) 防止超长文本溢出

3.2 筛选标签(FilterTabs)

筛选标签使用横向滚动的标签组实现:

@Builder
FilterTabs() {
  Row() {
    ForEach(this.filterLabels, (label: string, index: number) => {
      Text(label)
        .fontSize(13)
        .fontColor(index === this.activeFilter ? Color.White : '#666666')
        .fontWeight(index === this.activeFilter ? FontWeight.Medium : FontWeight.Normal)
        .padding({ left: 14, right: 14, top: 6, bottom: 6 })
        .backgroundColor(index === this.activeFilter ? '#2196F3' : '#EEEEEE')
        .borderRadius(14)
        .onClick(() => { this.activeFilter = index })
    }, (label: string) => label)
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 10, bottom: 6 })
  .justifyContent(FlexAlign.Start)
}

设计要点:

  • 选中态:蓝色背景 + 白色文字 → 突出显示
  • 非选中态:灰色背景 + 深色文字 → 可点击状态
  • 14px border-radius → 胶囊形状,符合 Material Design 规范
  • .justifyContent(FlexAlign.Start) → 左对齐,为后续新增筛选条件预留空间

3.3 记录卡片(CodeCard)

记录卡片是应用的核心信息载体,每条记录包含四个信息区域:

@Builder
CodeCard(item: CodeRecord) {
  Column() {
    Row() {
      // 左:应用图标
      Column() {
        Text(this.getAppIcon(item.appName)).fontSize(24).lineHeight(32)
      }
      .width(40).height(40)
      .backgroundColor('#F5F5F5').borderRadius(10)
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)

      // 中:应用名 + 验证码 + 时间
      Column() {
        Row() {
          Text(item.appName).fontSize(14).fontWeight(FontWeight.Medium).fontColor('#1A1A2E')
          Text(' · ' + item.purpose).fontSize(12).fontColor('#888888')
        }
        .alignItems(VerticalAlign.Center)

        Text(item.code).fontSize(20).fontWeight(FontWeight.Bold)
          .fontColor('#1A1A2E').letterSpacing(3).margin({ top: 2 })

        Row() {
          Text(item.source + ' · ' + item.time).fontSize(11).fontColor('#AAAAAA')
          Text('有效期至 ' + item.expireTime).fontSize(11).fontColor('#CCCCCC').margin({ left: 8 })
        }
        .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 12 }).layoutWeight(1)

      // 右:状态标签
      Column() {
        Text(this.statusLabels[item.status])
          .fontSize(11).fontColor(Color.White)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .backgroundColor(this.statusColors[item.status])
          .borderRadius(8)
      }
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)
    }
    .width('100%').padding(14)
  }
  .width('92%')
  .backgroundColor(Color.White)
  .borderRadius(14)
  .shadow({ radius: 4, color: Color.Black, offsetY: 2 })
  .alignSelf(ItemAlign.Center)
  .margin({ top: 8 })
  .onClick(() => {
    this.detailRecord = item
    this.showDetail = true
  })
}

信息层级设计:

第一层(最突出):验证码 382916 (20fp + Bold + letter-spacing:3)
第二层:应用名 + 用途 (14fp + Medium)
第三层(最弱化):来源 · 时间 | 有效期至 (11fp + 浅灰色)

这种层级分布遵循了「用户最关心验证码数字本身」的产品逻辑。

应用图标映射:

我们使用 Emoji 作为应用图标,通过字典映射实现:

getAppIcon(app: string): string {
  const map: Record<string, string> = {
    '微信': '💬', '支付宝': '🔵', '淘宝': '🛒', '企业微信': '🏢',
    'Apple ID': '🍎', 'QQ': '🐧', '抖音': '🎵', '美团': '🍔',
    'Steam': '🎮', 'GitHub': '🐙', '银行': '🏦'
  }
  return map[app] || '📱'
}

这种设计的好处:

  • 零外部依赖,无需引入图标库
  • Emoji 天然支持多平台渲染
  • 映射关系清晰,新增应用只需添加一行

3.4 详情弹窗(DetailDialog)

详情弹窗是用户查看和操作单条记录的核心界面:

@Builder
DetailDialog() {
  Column() {
    // 遮罩
    Column()
      .width('100%').height('100%')
      .backgroundColor(Color.Black).opacity(0.3)
      .onClick(() => { this.showDetail = false })

    // 弹窗卡片
    Column() {
      // 顶部色条(状态色)
      Row().width('100%').height(6)
        .backgroundColor(this.statusColors[this.detailRecord!.status])
        .borderRadius({ topLeft: 20, topRight: 20 })

      Column() {
        // 应用图标 + 名称
        Row() {
          Text(this.getAppIcon(this.detailRecord!.appName)).fontSize(36).lineHeight(44)
          Column() {
            Text(this.detailRecord!.appName).fontSize(18).fontWeight(FontWeight.Bold)
            Text(this.detailRecord!.purpose).fontSize(12).fontColor('#999999').margin({ top: 2 })
          }
          .margin({ left: 12 }).alignItems(HorizontalAlign.Start)
        }
        .alignItems(VerticalAlign.Center).width('100%')

        // 验证码大号展示 + 状态标签
        Row() {
          Text(this.detailRecord!.code)
            .fontSize(36).fontWeight(FontWeight.Bold)
            .fontColor('#1A1A2E').letterSpacing(6)
          Text(this.statusLabels[this.detailRecord!.status])
            .fontSize(12).fontColor(Color.White)
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .backgroundColor(this.statusColors[this.detailRecord!.status])
            .borderRadius(10).margin({ left: 12 })
        }
        .width('100%').margin({ top: 14 })
        .alignItems(VerticalAlign.Center)

        // 分隔线
        Row().width('100%').height(1).backgroundColor('#E8E8EE').margin({ top: 16, bottom: 12 })

        // 详情行
        this.DetailRow('📨 来源方式', this.detailRecord!.source)
        this.DetailRow('📅 接收时间', this.detailRecord!.date + ' ' + this.detailRecord!.time)
        this.DetailRow('⏳ 过期时间', this.detailRecord!.date + ' ' + this.detailRecord!.expireTime)
        this.DetailRow('🆔 记录编号', 'VC-' + this.detailRecord!.id.padStart(4, '0'))

        // 操作按钮
        Row() {
          if (this.detailRecord!.status === 0) {
            Button('标记为已使用')
              .height(40).backgroundColor('#4CAF50').borderRadius(20)
              .fontColor(Color.White).fontSize(13).layoutWeight(1)
              .onClick(() => {
                this.detailRecord!.status = 1
                this.showDetail = false
              })
          }
          Button('关闭')
            .height(40).backgroundColor('#F5F5F5').borderRadius(20)
            .fontColor('#666666').fontSize(13)
            .layoutWeight(this.detailRecord!.status === 0 ? 0 : 1)
            .margin({ left: this.detailRecord!.status === 0 ? 8 : 0 })
            .onClick(() => { this.showDetail = false })
        }
        .width('100%').margin({ top: 16 })
      }
      .padding(20)
    }
    .width('85%').backgroundColor(Color.White).borderRadius(20)
    .shadow({ radius: 24, color: Color.Black, offsetY: 8 })
  }
  .width('100%').height('100%')
  .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
  .onClick(() => { this.showDetail = false })
  .transition(TransitionEffect.opacity(0))
}

弹窗交互设计要点:

  1. 遮罩层:点击遮罩区域关闭弹窗(移动端通用模式)
  2. 顶部色条:使用状态色(蓝/绿/橙),视觉上预览状态
  3. 验证码放大展示:36fp + letter-spacing:6,方便用户核对
  4. 条件按钮:仅「未使用」状态显示「标记为已使用」按钮
  5. 按钮布局自适应:通过 .layoutWeight() 动态调整按钮宽度
  6. 过渡动画TransitionEffect.opacity(0) 实现淡入效果

3.5 添加记录弹窗(AddSheet)

添加记录弹窗提供了快速填充预设记录的功能:

@Builder
AddSheet() {
  // ... 遮罩层省略

  Column() {
    Text('📝 记录新验证码').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#1A1A2E')
      .margin({ top: 6, bottom: 14 })

    Row().width('100%').height(1).backgroundColor('#E8E8EE').margin({ bottom: 14 })

    Row() {
      Text('💡').fontSize(16)
      Text('点击下方按钮可快速添加示例记录').fontSize(13).fontColor('#888888').margin({ left: 6 })
    }
    .width('100%').margin({ bottom: 14 })

    Column() {
      this.AddPresetBtn('登录验证', '短信', '微信', '382916')
      this.AddPresetBtn('支付确认', '短信', '支付宝', '520137')
      this.AddPresetBtn('注册账号', '邮件', 'GitHub', '651204')
    }
    .width('100%')

    Button('取消').width('100%').height(40)
      .backgroundColor('#F5F5F5').borderRadius(20)
      .fontColor('#666666').fontSize(14).margin({ top: 14 })
      .onClick(() => { this.showAddSheet = false })
  }
  .width('85%').padding(20)
  .backgroundColor(Color.White).borderRadius(20)
  .shadow({ radius: 24, color: Color.Black, offsetY: 8 })
}

预设按钮使用 AddPresetBtn 构建:

@Builder
AddPresetBtn(purpose: string, source: string, appName: string, code: string) {
  Row() {
    Text(this.getAppIcon(appName)).fontSize(20).lineHeight(28)
    Column() {
      Text(appName + ' · ' + purpose).fontSize(13).fontColor('#333333')
      Text(code).fontSize(16).fontWeight(FontWeight.Bold)
        .fontColor('#1A1A2E').letterSpacing(2)
    }
    .alignItems(HorizontalAlign.Start).margin({ left: 10 }).layoutWeight(1)

    Text('+添加').fontSize(12).fontColor('#2196F3').fontWeight(FontWeight.Medium)
  }
  .width('100%').padding(12)
  .backgroundColor('#F8F9FA').borderRadius(10).margin({ bottom: 8 })
  .alignItems(VerticalAlign.Center)
  .onClick(() => {
    let newRecord: CodeRecord = {
      id: (this.records.length + 1).toString(),
      code: code, purpose: purpose, source: source,
      appName: appName, time: '刚刚', date: this.today(),
      expireTime: '10分钟后', status: 0
    }
    this.records.unshift(newRecord)
    this.showAddSheet = false
  })
}

点击后执行三个操作:

  1. 创建新的 CodeRecord 对象
  2. 通过 unshift 插入到数组头部(最新记录在最上面)
  3. 关闭添加弹窗

第四章:ForEach 与列表渲染深入解析

4.1 ForEach 的工作原理

ArkTS 中的 ForEach 是列表渲染的核心 API,其完整签名如下:

ForEach(
  arr: any[],                                    // 数据源数组
  itemGenerator: (item: any, index?: number) => void,  // 组件生成函数
  keyGenerator?: (item: any, index?: number) => string  // key 生成器(可选)
)

在本应用中,我们使用 ForEach 渲染验证码列表:

ForEach(this.filteredRecords(), (item: CodeRecord) => {
  this.CodeCard(item)
}, (item: CodeRecord) => item.id)

4.2 key 生成器的重要性

第三个参数 keyGenerator 虽然可选,但在实践中强烈建议提供。它的作用是为每个列表项生成一个稳定且唯一的标识符,帮助 ArkTS 框架在列表更新时精确识别:

  • :新添加的项 → 创建新组件
  • :被移除的项 → 销毁对应组件
  • :属性变化的项 → 仅更新该组件
  • :位置变化的项 → 移动组件位置而不重建

如果不提供 key 生成器,ArkTS 会使用默认的索引作为 key。这在列表项增删时会导致所有后续项被重新创建,严重影响性能。

4.3 列表性能优化

对于验证码列表这种数据量不大的场景(通常不超过 100 条),ForEach 的性能完全足够。但如果数据量增长到数千条,可以考虑以下优化策略:

  • 虚拟滚动:使用 LazyForEach 替代 ForEach,仅渲染可视区域内的项
  • 数据分页:每次只加载和展示部分数据
  • 不可变数据:修改 records 数组时创建新数组而非修改原数组

对于本应用,10 条数据完全在 ForEach 的最佳工作范围内。

4.4 筛选驱动的列表更新

当用户切换筛选标签时,filteredRecords() 返回不同的数组。ForEach 通过 key 生成器识别哪些记录需要保留、哪些需要移除:

切换前(activeFilter=0,全部显示):
  记录A(id:1)  记录B(id:2)  记录C(id:3)  ...  记录J(id:10)

切换后(activeFilter=1,仅显示未使用):
  filteredRecords() → [记录A(status=0)]
  ForEach 比较 key → 只留下记录A,移除记录B-J

由于我们使用 item.id 作为 key,框架可以精确执行最小化更新。

第五章:Emoji 图标系统设计

5.1 Emoji 作为图标源的优势

本应用为 10 个应用分配了对应的 Emoji 图标。选择 Emoji 而非传统图标方案(iconfont、SVG 或图片资源),基于以下考量:

方案 包体积 加载速度 维护成本 跨设备一致性
Emoji 0(系统内置) 即时 极低(一行映射) 系统级保证
iconfont ~5-20KB 需加载字体 中等 依赖版本
SVG 图标 ~1-3KB/个 需编译打包 高(需设计) 一致
图片资源 ~2-10KB/个 需解码 高(需设计) 一致

对于验证码记录这种工具型应用,Emoji 方案在开发效率和包体积上有显著优势。

5.2 映射表设计

getAppIcon(app: string): string {
  const map: Record<string, string> = {
    '微信': '💬', '支付宝': '🔵', '淘宝': '🛒', '企业微信': '🏢',
    'Apple ID': '🍎', 'QQ': '🐧', '抖音': '🎵', '美团': '🍔',
    'Steam': '🎮', 'GitHub': '🐙', '银行': '🏦'
  }
  return map[app] || '📱'   // 默认图标
}

设计原则:

  1. 语义关联:每个 Emoji 都与应用特性相关——微信用💬(聊天)、淘宝用🛒(购物)、Steam用🎮(游戏)
  2. 默认兜底:未收录的应用使用📱作为通用图标
  3. 扩展友好:新增应用只需添加一行映射,无需重新编译资源

5.3 在列表中展示图标

图标在记录卡片中以 40×40 的圆形灰色容器展示:

Column() {
  Text(this.getAppIcon(item.appName))
    .fontSize(24)
    .lineHeight(32)
}
.width(40).height(40)
.backgroundColor('#F5F5F5')
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)

灰色背景(#F5F5F5)+圆角(10px)+居中布局,形成了类似 iOS App 图标的视觉效果。

5.4 Emoji 排版注意事项

将 Emoji 放在 Text 组件中展示时,需要注意:

Text('💬').fontSize(24).lineHeight(32)
  • fontSize 控制 Emoji 的大小
  • lineHeight 控制垂直空间,避免 Emoji 被截断
  • 不同 Emoji 的渲染尺寸可能有细微差异,建议留出 20-30% 的 padding 余量

5.5 从 Emoji 映射看数据驱动设计

Emoji 图标映射表的设计体现了 ArkTS 开发中的一个重要原则:数据与 UI 分离。映射表 getAppIcon() 是一个纯函数——输入应用名,输出 Emoji。这种设计的好处是:

  1. 可测试性:纯函数无需依赖 UI 状态,可以单独验证
  2. 可扩展性:新增应用时只需添加映射条目,不影响其他代码
  3. 关注点分离:UI 组件只负责展示,图标选择逻辑交给映射函数

这一原则贯穿整个应用的设计——filteredRecords() 是另一个纯函数,负责数据筛选;getGridHeight() 负责布局计算。每个函数职责单一,组合在一起形成了清晰的数据流管道。

第六章:交互反馈与状态变更

6.1 状态标记操作流

验证码状态标记是应用的核心操作,其完整流程如下:

用户打开详情弹窗
    │
    ├── 如果 status === 0(未使用)
    │      显示「标记为已使用」按钮
    │      ↓ 点击按钮
    │      detailRecord!.status = 1
    │      showDetail = false
    │      ↓
    │      UI 自动更新:
    │        • 列表中的标签从「未使用」(蓝) 变为「已使用」(绿)
    │        • 统计卡片「未使用」-1,「已使用」+1
    │
    └── 如果 status !== 0(已使用或已过期)
          只显示「关闭」按钮
          无法修改状态(防止误操作)

6.2 按钮的条件渲染

条件渲染通过 if 语句实现:

Row() {
  if (this.detailRecord!.status === 0) {
    Button('标记为已使用')
      .backgroundColor('#4CAF50')
      .layoutWeight(1)                  // 占用剩余空间
      .onClick(() => {
        this.detailRecord!.status = 1
        this.showDetail = false
      })
  }
  Button('关闭')
    .layoutWeight(this.detailRecord!.status === 0 ? 0 : 1)  // 自适应宽度
    .margin({ left: this.detailRecord!.status === 0 ? 8 : 0 })
    .onClick(() => { this.showDetail = false })
}

按钮宽度自适应逻辑:

  • status === 0:两个按钮并排。「标记」按钮占满剩余宽度(layoutWeight:1),「关闭」按钮仅占自身宽度(layoutWeight:0)
  • status !== 0:仅「关闭」按钮,占满整个宽度(layoutWeight:1)

这种设计让按钮布局在不同状态下都能充分利用空间。

6.3 平滑的状态色过渡

三种状态使用三种不同的语义色,通过数组索引快速访问:

private statusColors: string[] = ['#2196F3', '#4CAF50', '#FF9800']
private statusLabels: string[] = ['未使用', '已使用', '已过期']

使用方式:this.statusColors[item.status]this.statusLabels[item.status]

这种「索引即状态」的设计模式避免了冗长的 switch-case 或 if-else 判断。

6.4 弹窗关闭的四种路径

路径 触发方式 代码实现
点击遮罩 外层遮罩的 onClick this.showDetail = false
点击关闭按钮 Button 的 onClick this.showDetail = false
标记后自动关闭 操作成功后的 onClick this.detailRecord!.status = 1; this.showDetail = false
点击取消(添加弹窗) Button 的 onClick this.showAddSheet = false

所有路径最终都归结为将 showDetailshowAddSheet 设置为 false,ArkTS 的条件渲染机制会移除对应的 UI 子树。


第七章:API 24 关键适配点

7.1 ItemAlign 的使用

在 API 24 中,alignSelf() 属性方法的参数类型为 ItemAlign,而非之前版本的 Alignment

// ✅ API 24 正确用法
.alignSelf(ItemAlign.Center)

// ❌ 旧版本用法(编译错误)
.alignSelf(Alignment.Center)

在各布局中的作用:

布局 主轴 交叉轴 ItemAlign.End 效果
Row 水平 垂直 底部对齐
Column 垂直 水平 右侧对齐

7.2 textOverflow 参数对象化

.textOverflow() 在 API 24 中必须使用对象参数:

// ✅ API 24
.textOverflow({ overflow: TextOverflow.Ellipsis })

// ❌ 旧版本
.textOverflow(TextOverflow.Ellipsis)

TextOverflowOptions 对象除了 overflow 属性外,还支持未来扩展其他配置项。

7.3 justifiyContent 使用 FlexAlign

justifyContent() 方法在所有版本中都使用 FlexAlign 枚举:

// 正确用法
.justifyContent(FlexAlign.Center)
.justifyContent(FlexAlign.SpaceEvenly)
.justifyContent(FlexAlign.Start)

// ❌ 错误用法(常见错误)
.justifyContent(VerticalAlign.Center)  // 类型不匹配!

7.4 TransitionEffect

在 API 24 中,过渡动画使用 TransitionEffect API:

// ✅ API 24
.transition(TransitionEffect.opacity(0))

// ✅ 支持链式组合
.transition(TransitionEffect.opacity(0).translate({ y: 50 }))

7.5 @Builder 中的 let 限制

API 24 禁止在 @Builder 中使用 let 声明局部变量:

// ❌ 编译错误
@Builder
DetailDialog() {
  let item = this.detailRecord  // 不允许!
  Column() { ... }
}

// ✅ 正确做法:使用非空断言直接访问
Text(this.detailRecord!.code)
Text(this.detailRecord!.appName)

如果变量被多处引用,可考虑将其提取为 struct 的方法:

// 在 struct 中定义方法
get currentDetail(): CodeRecord {
  return this.detailRecord!
}

// 在 @Builder 中调用
Text(this.currentDetail.code)

第八章:完整代码结构分析

8.1 文件整体结构

Index.ets (~523行)
│
├── 第1-26行:@Entry @Component struct 定义 + @State 变量 + 模拟数据
├── 第27-114行:build() 方法 — 页面根布局
│   ├── Stack × 3层(背景/内容/弹窗)
│   ├── Scroll + 主内容
│   └── 条件渲染弹窗
├── 第115-120行:filteredRecords() 方法
├── 第122-155行:@Builder StatsCards + StatItem
├── 第157-177行:@Builder FilterTabs
├── 第179-257行:@Builder CodeCard
├── 第259-371行:@Builder DetailDialog
├── 第373-435行:@Builder AddSheet + AddPresetBtn
├── 第437-448行:@Builder DetailRow
├── 第450-523行:辅助方法 + interface CodeRecord

8.2 核心方法一览

方法 类型 作用
filteredRecords() 私有方法 根据 activeFilter 返回筛选后的记录
getAppIcon(app) 私有方法 应用名→Emoji 映射
today() 私有方法 获取当前日期字符串 YYYY-MM-DD
StatsCards() @Builder 统计概览卡片
StatItem() @Builder 单张统计卡片
FilterTabs() @Builder 筛选标签组
CodeCard(item) @Builder 单条记录卡片
DetailDialog() @Builder 详情弹窗
AddSheet() @Builder 添加记录弹窗
AddPresetBtn() @Builder 预设添加按钮
DetailRow(label, value) @Builder 详情信息行

第九章:交互体验优化

9.1 点击反馈

记录卡片点击后打开详情弹窗,用户获得即时反馈:

.onClick(() => {
  this.detailRecord = item    // 更新当前查看的记录
  this.showDetail = true      // 触发弹窗显示
})

两步操作的设计让状态变化清晰可控。detailRecord 先被设置,然后 showDetail 触发渲染,确保弹窗打开时数据已经就位。

9.2 弹窗关闭逻辑

弹窗关闭有四种途径:

关闭方式 实现 适用场景
点击遮罩 外层 Column 的 onClick 误触弹窗,快速关闭
点击「关闭」按钮 Button 的 onClick 主动关闭
点击「标记为已使用」 Button 的 onClick + 状态变更 操作完成后自动关闭
点击「取消」按钮 Button 的 onClick(添加弹窗) 放弃添加

所有这些关闭操作都将对应的 show 变量设置为 false

9.3 状态变更的即时反馈

当用户将验证码标记为「已使用」时,以下 UI 同时更新:

  1. 详情弹窗关闭 → 回到列表
  2. 列表中的该记录状态标签从蓝色「未使用」变为绿色「已使用」
  3. 顶部统计卡片重新计数:未使用 -1,已使用 +1

这一切只需要一行代码:

this.detailRecord!.status = 1

ArkTS 的响应式系统自动推导出所有受影响的 UI 部分并执行最小化更新。


第十章:模拟数据设计

10.1 数据样本

我们准备了 10 条模拟数据,覆盖不同的应用、用途和状态:

private records: CodeRecord[] = [
  { id: '1', code: '382916', purpose: '登录验证', source: '短信', appName: '微信',
    time: '09:32', date: '2025-06-09', expireTime: '09:42', status: 0 },
  { id: '2', code: '651204', purpose: '注册账号', source: '邮件', appName: 'GitHub',
    time: '09:15', date: '2025-06-09', expireTime: '09:25', status: 1 },
  // ... 其余 8 条
]

数据覆盖情况:

维度 覆盖
应用 微信、支付宝、淘宝、GitHub、QQ、抖音、美团、Steam、Apple ID、企业微信
用途 登录验证、注册账号、重置密码、支付确认、修改信息
来源 短信(6条)、邮件(3条)、语音(1条)
状态 未使用×1、已使用×5、已过期×4
时间 当天、昨天、06-07、06-06

10.2 添加记录的动态数据生成

当用户通过添加弹窗添加记录时,新记录的 id 自动递增,date 使用当天日期,time 设置为「刚刚」:

let newRecord: CodeRecord = {
  id: (this.records.length + 1).toString(),  // 自增ID
  code: code, purpose: purpose, source: source,
  appName: appName, time: '刚刚',
  date: this.today(),                         // 当天日期
  expireTime: '10分钟后',                     // 模拟相对时间
  status: 0                                    // 新记录默认为「未使用」
}
this.records.unshift(newRecord)               // 插入到列表最前面

第十一章:构建与部署

11.1 构建过程

本应用在 API 24 环境下的完整构建日志如下:

CompileArkTS... 1s 148ms
PackageHap...   476ms
BUILD SUCCESSFUL in 3s 563ms

核心编译阶段 CompileArkTS 仅需约 1.15 秒,这得益于 ArkCompiler 的 AOT 编译优化和 hvigor 的增量编译能力。

11.2 常见编译错误及对照

错误信息 原因 解决方案
Argument of type 'VerticalAlign' is not assignable to 'FlexAlign' Column的justifyContent误用VerticalAlign 改用 FlexAlign.Center
Argument of type 'Alignment' is not assignable to 'ItemAlign' alignSelf误用Alignment 改用 ItemAlign.Center
Unterminated string constant 字符串未闭合,如 '100%) 改为 '100%')
Only UI component syntax @Builder内有let声明 移除let,直接访问属性

11.3 应用配置

build-profile.json5 中的关键配置:

{
  "app": {
    "products": [
      {
        "name": "default",
        "targetSdkVersion": "6.1.1(24)",
        "compatibleSdkVersion": "6.1.1(24)",
        "runtimeOS": "HarmonyOS"
      }
    ]
  }
}

第十二章:总结与展望

12.1 项目回顾

这款「验证码使用记录APP」虽然只有约 523 行代码,但实现了完整的产品闭环:

  • 数据层:10条模拟数据,覆盖 3 种状态、10 个应用、5 种用途
  • 展示层:统计卡片、筛选标签、记录列表、详情弹窗、添加弹窗
  • 交互层:点击查看、状态标记、筛选切换、快速添加
  • 技术层:5 个 @State 变量、12 个 @Builder 方法、8 个 API 24 适配点

12.2 关键技术收获

  1. API 24 的严格类型检查ItemAlign 替代 AlignmentFlexAlign 的正确使用、@Builder 的限制,这些变化让 ArkTS 代码更加健壮
  2. 单向数据流的可预测性:所有状态变更都通过明确的路径进行,UI 自动响应
  3. @Builder 组件化设计:将 UI 拆分为 12 个 @Builder 方法,每个方法职责单一,代码可维护性好

12.3 扩展方向

当前版本作为 MVP,未来可以从以下方向扩展:

功能扩展:

  • 接入短信读取权限(@ohos.telephony.sms),自动识别并记录验证码
  • 定时清理过期验证码(自动删除 status=2 超过 7 天的记录)
  • 搜索功能(按应用名/验证码搜索)
  • 数据导出(导出为 JSON/CSV 格式)

体验优化:

  • 长按记录卡片出现快捷操作菜单
  • 滑动标记已使用/删除(通过 Swiper 组件)
  • 暗色模式(通过 @Styles 和主题变量)
  • 数据持久化(通过 @ohos.data.preferences@ohos.data.relationalStore

技术升级:

  • 引入 @ohos.data.distributedKVStore 实现多设备数据同步
  • 通过 @ohos.backgroundTaskManager 实现后台验证码监控
  • 集成 @ohos.security.privacyKit 确保验证码数据安全

附录:完整代码快速导航

A. 数据接口

interface CodeRecord {
  id: string
  code: string
  purpose: string
  source: string
  appName: string
  time: string
  date: string
  expireTime: string
  status: number  // 0=未使用, 1=已使用, 2=已过期
}

B. 关键状态变量

@State records: CodeRecord[] = [ /* 10条模拟数据 */ ]
@State activeFilter: number = 0           // 0=全部, 1=未使用, 2=已使用, 3=已过期
@State showDetail: boolean = false        // 详情弹窗显隐
@State detailRecord: CodeRecord | null = null  // 当前查看记录
@State showAddSheet: boolean = false      // 添加弹窗显隐

C. 筛选方法

filteredRecords(): CodeRecord[] {
  if (this.activeFilter === 0) return this.records
  return this.records.filter(r => r.status === this.activeFilter - 1)
}

D. 状态常量

private statusLabels: string[] = ['未使用', '已使用', '已过期']
private statusColors: string[] = ['#2196F3', '#4CAF50', '#FF9800']
private filterLabels: string[] = ['全部', '未使用', '已使用', '已过期']

项目信息:

  • 开发工具:DevEco Studio 5.0.3
  • 目标 API:24(HarmonyOS 6.1.1)
  • 开发语言:ArkTS(Stage 模型)
  • 源码行数:~523 行
  • 构建耗时:~3.6 秒
  • @Builder 数量:12 个
  • @State 变量:5 个
  • 模拟数据:10 条

参考资源:

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐