鸿蒙实战:BindRelationPage 四步状态机与邀请卡倒计时

鸿蒙实战:BindRelationPage 四步状态机与邀请卡倒计时

前言

图:鸿蒙实战第95篇:BindRelationPage 四步状态机与邀请卡倒计时 运行效果截图(HarmonyOS NEXT)

BindRelationPage 是"鹿鹿"最"仪式感"的功能------NFC 碰一碰绑定伴侣。这 787 行代码的核心是一个四步状态机 :从选择绑定方式,到发出邀请,到收到对方邀请,最终完成绑定。每一步都有独立的 UI 视图和 @Builder 组件。

本篇深入拆解:

  1. 四步状态机设计select → sent → incoming → success
  2. 4 小时邀请倒计时setInterval + 格式化显示)
  3. 邀请卡 UI 设计(双头像、关系码、倒计时)
  4. DB 写入 + AppStorage 同步的绑定完成处理

系列导航:94 CoupleHomePage · 96 SharePage
相关文档:setInterval Timer · relationalStore · NFC Kit


一、四步状态机

1.1 状态流转图

#mermaid-svg-Btq7r5mV0oH64SdB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Btq7r5mV0oH64SdB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Btq7r5mV0oH64SdB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Btq7r5mV0oH64SdB .error-icon{fill:#552222;}#mermaid-svg-Btq7r5mV0oH64SdB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Btq7r5mV0oH64SdB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Btq7r5mV0oH64SdB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Btq7r5mV0oH64SdB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Btq7r5mV0oH64SdB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Btq7r5mV0oH64SdB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Btq7r5mV0oH64SdB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Btq7r5mV0oH64SdB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Btq7r5mV0oH64SdB .marker.cross{stroke:#333333;}#mermaid-svg-Btq7r5mV0oH64SdB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Btq7r5mV0oH64SdB p{margin:0;}#mermaid-svg-Btq7r5mV0oH64SdB defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-Btq7r5mV0oH64SdB g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-Btq7r5mV0oH64SdB g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-Btq7r5mV0oH64SdB g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-Btq7r5mV0oH64SdB g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-Btq7r5mV0oH64SdB g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-Btq7r5mV0oH64SdB .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-Btq7r5mV0oH64SdB .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-Btq7r5mV0oH64SdB .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-Btq7r5mV0oH64SdB .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Btq7r5mV0oH64SdB .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-Btq7r5mV0oH64SdB .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-Btq7r5mV0oH64SdB .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-Btq7r5mV0oH64SdB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Btq7r5mV0oH64SdB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Btq7r5mV0oH64SdB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Btq7r5mV0oH64SdB .edgeLabel .label text{fill:#333;}#mermaid-svg-Btq7r5mV0oH64SdB .label div .edgeLabel{color:#333;}#mermaid-svg-Btq7r5mV0oH64SdB .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-Btq7r5mV0oH64SdB .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-Btq7r5mV0oH64SdB .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-Btq7r5mV0oH64SdB .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-Btq7r5mV0oH64SdB .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-Btq7r5mV0oH64SdB .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Btq7r5mV0oH64SdB .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Btq7r5mV0oH64SdB #statediagram-barbEnd{fill:#333333;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Btq7r5mV0oH64SdB .cluster-label,#mermaid-svg-Btq7r5mV0oH64SdB .nodeLabel{color:#131300;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-Btq7r5mV0oH64SdB .note-edge{stroke-dasharray:5;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagram-note text{fill:black;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagram-note .nodeLabel{color:black;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagram .edgeLabel{color:red;}#mermaid-svg-Btq7r5mV0oH64SdB #dependencyStart,#mermaid-svg-Btq7r5mV0oH64SdB #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-Btq7r5mV0oH64SdB .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Btq7r5mV0oH64SdB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 进入页面
点击"发起邀请"\n或 NFC 碰一碰
收到对方邀请\n(深度链接 / NFC)
对方也发起邀请
用户取消 / 超时
点击"接受邀请"\nDB 写入成功
拒绝邀请
跳转 CoupleHomePage
select
sent
incoming
success

1.2 状态枚举与切换

typescript 复制代码
type BindStep = 'select' | 'sent' | 'incoming' | 'success'

@Entry
@Component
struct BindRelationPage {
  @State currentStep: BindStep = 'select'

  // 各步骤特有状态
  @State inviteCode: string = ''      // 发出的邀请码(L·847·M 格式)
  @State countdownSecs: number = 14400  // 4小时 = 14400秒
  @State partnerAvatar: string = ''   // 对方头像
  @State partnerName: string = '未知用户'

  private countdownTimer: number = -1
}

1.3 五个 @Builder 视图

每一步对应一个独立的 @Builderbuild() 中根据 currentStep 条件渲染:

typescript 复制代码
build() {
  Column() {
    this.TitleBar()
    Scroll() {
      Column() {
        if (this.currentStep === 'select') {
          this.SelectView()    // 选择绑定方式
        } else if (this.currentStep === 'sent') {
          this.InviteCardView()  // 邀请卡(含倒计时)
        } else if (this.currentStep === 'incoming') {
          this.IncomingView()  // 收到邀请视图
        } else if (this.currentStep === 'success') {
          this.SuccessView()   // 绑定成功
        }
      }
    }
  }
}

二、邀请卡设计

2.1 关系码生成

typescript 复制代码
private generateInviteCode(): string {
  // 格式:L·8 4 7·M(首字母 · 三位随机数 · 对方首字母)
  const digits = Array.from({ length: 3 }, () =>
    Math.floor(Math.random() * 10).toString()
  ).join(' ')
  return `L·${digits}·M`
}

关系码采用"符文风格"设计(· 间隔符),增加仪式感。实际产品中可替换为真实的 UUID 或服务端生成的邀请 token。

2.2 邀请卡 @Builder

typescript 复制代码
@Builder
InviteCardView() {
  Column({ space: 20 }) {
    // 卡片主体
    Column({ space: 16 }) {
      // 标签行:POSITION + PENDING
      Row({ space: 8 }) {
        Text('POSITION')
          .fontSize(10).letterSpacing(2)
          .fontColor(AppColors.TEXT_3)
        Text('PENDING')
          .fontSize(10).letterSpacing(2)
          .fontColor(AppColors.WARM)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .backgroundColor('rgba(217,166,137,0.15)')
          .borderRadius(4)
      }

      // 双头像(From → To)
      Row({ space: 20 }) {
        this.AvatarBubble('我', AppColors.PRIMARY, false)
        Text('→').fontSize(20).fontColor(AppColors.TEXT_3)
        this.AvatarBubble('TA', AppColors.ROSE, true)  // 对方:虚线边框
      }
      .justifyContent(FlexAlign.Center)

      // 关系码
      Text(this.inviteCode)
        .fontSize(28)
        .fontFamily(AppFonts.SERIF)
        .fontWeight(AppFonts.W_MEDIUM)
        .letterSpacing(4)
        .fontColor(AppColors.TEXT)

      // 有效期说明
      Text(`有效期 ${this.formatCountdown(this.countdownSecs)}`)
        .fontSize(12)
        .fontColor(AppColors.TEXT_2)
    }
    .width('100%')
    .padding(24)
    .backgroundColor(AppColors.CARD)
    .borderRadius(AppRadius.CARD)
    .shadow({ radius: 20, color: 'rgba(168,144,122,0.15)', offsetY: 8 })

    // 取消按钮
    Button('取消邀请')
      .type(ButtonType.Capsule)
      .backgroundColor('transparent')
      .fontColor(AppColors.TEXT_2)
      .onClick(() => {
        this.stopCountdown()
        this.currentStep = 'select'
      })
  }
}

2.3 头像气泡 Builder

typescript 复制代码
@Builder
AvatarBubble(initial: string, color: string, isDashed: boolean) {
  Column() {
    Text(initial)
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .fontColor(Color.White)
  }
  .width(60)
  .height(60)
  .borderRadius(30)
  .backgroundColor(isDashed ? 'transparent' : color)
  .border({
    width: isDashed ? 2 : 0,
    color: isDashed ? AppColors.TEXT_3 : 'transparent',
    style: isDashed ? BorderStyle.Dashed : BorderStyle.Solid
  })
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
}

"对方"头像使用虚线边框(BorderStyle.Dashed)表示"等待连接"的悬念状态。


三、4 小时倒计时

3.1 启动与停止

typescript 复制代码
private startCountdown(): void {
  this.countdownSecs = 4 * 60 * 60  // 4 小时 = 14400 秒
  this.countdownTimer = setInterval(() => {
    this.countdownSecs -= 1
    if (this.countdownSecs <= 0) {
      this.stopCountdown()
      // 邀请超时:回到 select 步骤
      promptAction.showToast({ message: '邀请已过期,请重新发起' })
      this.currentStep = 'select'
    }
  }, 1000)
}

private stopCountdown(): void {
  if (this.countdownTimer !== -1) {
    clearInterval(this.countdownTimer)
    this.countdownTimer = -1
  }
}

3.2 倒计时格式化

typescript 复制代码
private formatCountdown(secs: number): string {
  const h = Math.floor(secs / 3600)
  const m = Math.floor((secs % 3600) / 60)
  const s = secs % 60
  if (h > 0) {
    return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
  }
  return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
剩余时间 显示格式 示例
>1 小时 H:MM:SS 3:59:47
<1 小时 MM:SS 45:23
0 触发超时处理 ---

3.3 组件销毁时清理

typescript 复制代码
aboutToDisappear(): void {
  this.stopCountdown()  // 必须清理,防止内存泄漏
}

四、收到邀请视图

4.1 收到邀请 UI 要点

typescript 复制代码
@Builder
IncomingView() {
  Column({ space: 20 }) {
    // 外发光头像(borderWidth: 6 + 阴影模拟光晕)
    Column() {
      Text(this.partnerName[0])
        .fontSize(28).fontColor(Color.White)
    }
    .width(80).height(80).borderRadius(40)
    .backgroundColor(AppColors.ROSE)
    .border({ width: 6, color: 'rgba(201,160,160,0.3)' })
    .shadow({ radius: 20, color: 'rgba(201,160,160,0.4)' })

    // 留言气泡(左侧竖线风格)
    Row() {
      Column()
        .width(3).height(48)
        .backgroundColor(AppColors.ROSE)
        .borderRadius(2)
        .margin({ right: 12 })
      Text('"想了解你更多,愿意和我建立连接吗?"')
        .fontSize(13)
        .fontColor(AppColors.TEXT)
        .lineHeight(22)
    }
    .padding(16)
    .backgroundColor(AppColors.CARD)
    .borderRadius(AppRadius.CARD)

    // 权限说明列表
    Column({ space: 8 }) {
      Text('接受后双方可以:')
        .fontSize(12).fontColor(AppColors.TEXT_2)
      ForEach([
        '查看对方的笔迹分析摘要',
        '生成双人合盘报告',
        '在对方首页看到你的状态'
      ], (item: string) => {
        Row({ space: 8 }) {
          Text('✓').fontColor(AppColors.QUIET).fontSize(12)
          Text(item).fontSize(12).fontColor(AppColors.TEXT_2)
        }
      })
    }

    // 接受 / 拒绝 按钮
    Row({ space: 12 }) {
      Button('接受邀请')
        .onClick(() => this.acceptInvitation())
      Button('暂不')
        .backgroundColor('transparent')
        .fontColor(AppColors.TEXT_2)
        .onClick(() => { this.currentStep = 'select' })
    }
  }
}

五、绑定完成处理

5.1 DB 写入 + AppStorage 同步

typescript 复制代码
private async acceptInvitation(): Promise<void> {
  try {
    const userId = AppStorage.get<number>('user_id') ?? 1

    // ① 创建档案(relation = '伴侣')
    const archiveId = await ArchiveDao.create({
      user_id: userId,
      name: this.partnerName,
      relation: '伴侣',
      record_count: 0,
      created_at: Date.now()
    })

    // ② 创建关系记录
    await RelationDao.create({
      user_id: userId,
      partner_name: this.partnerName,
      bound_method: 'invite_code',
      created_at: Date.now()
    })

    // ③ 同步到 AppStorage
    AppStorage.setOrCreate<boolean>('couple_bound', true)
    AppStorage.setOrCreate<string>('couple_partner_name', this.partnerName)

    // ④ 切换到成功状态
    this.currentStep = 'success'
    this.stopCountdown()

  } catch (e) {
    hilog.error(TAG, '绑定失败: %{public}s', JSON.stringify(e))
    promptAction.showToast({ message: '绑定失败,请重试' })
  }
}

5.2 成功动画

typescript 复制代码
@Builder
SuccessView() {
  Column({ space: 24 }) {
    // 对勾圆圈(缩放入场动画)
    Column() {
      Text('✓')
        .fontSize(40)
        .fontColor(Color.White)
    }
    .width(100).height(100).borderRadius(50)
    .backgroundColor(AppColors.PRIMARY)
    .justifyContent(FlexAlign.Center)
    .transition({ type: TransitionType.Insert, scale: { x: 0, y: 0 } })

    Text(`与 ${this.partnerName} 的连接已建立 💕`)
      .fontSize(18).fontWeight(FontWeight.Medium)

    Button('进入双人空间')
      .onClick(() => {
        this.getUIContext().getRouter().replaceUrl({
          url: 'pages/CoupleHomePage'
        })
      })
  }
}

六、注意事项与常见问题

6.1 开发注意事项

在实际开发过程中,需特别注意以下几点:

  • API 兼容性:部分接口仅在特定 HarmonyOS NEXT 版本中可用,需做版本条件判断
  • 权限模型:采用静态声明(module.json5)+ 动态申请(requestPermissionsFromUser)的两阶段授权
  • 生命周期 :合理使用 aboutToAppear()aboutToDisappear() 管理资源初始化与释放
  • 状态同步 :跨页面数据通过 AppStorage 共享,组件内状态使用 @State / @Prop / @Link 装饰器

6.2 常见错误与解决方案

常见问题快速排查表:

问题类型 排查方向 参考方法
应用崩溃 查看 hilog 错误日志 hilog.error(TAG, "...", e.message)
状态丢失 检查 AppStorage 键名拼写 统一使用常量管理键名
动画不流畅 避免在 animateTo 回调中执行 I/O 动画与数据操作分离

七、最佳实践与性能优化

7.1 代码组织规范

以下规范可显著提升代码可读性与可维护性:

  • 分层解耦:UI 逻辑(pages/)与数据操作(DAO/Service)严格分离,不在 build() 中调用数据库
  • 组件拆分 :单个 @Component 保持 100~200 行以内,大型组件拆分为 @Builder 子函数
  • 令牌约束 :颜色、字号、间距统一引用 AppColors / AppFonts / AppAnimations,禁止魔数
  • 错误处理 :异步方法统一返回 Promise<T | null>,异常时 hilog.error 记录并返回 null

7.2 性能优化要点

以下是常见性能优化措施的对比分析:

优化维度 优化前 优化后 效果
列表渲染 ForEach 全量渲染 LazyForEach 按需渲染 内存降低 40-60%
图片加载 不指定尺寸 指定 width/height 减少布局重计算
数据库查询 每次重新查询 合理缓存 + 精确 Predicates 查询速度提升 5-10x
动画实现 逐帧手动绘制 animateTo 属性动画 稳定 60fps
状态更新 全局刷新 @State 精细化作用域 减少无效渲染

八、调试与上线建议

8.1 调试技巧

在开发关系绑定流程时,以下调试方法可大幅提升效率:

  • 在各状态转换点打印 AppStorage 快照,确认数据同步序列正确
  • 倒计时调试时可将 4 小时改为 30 秒,加速测试流程
  • NFC 碑写失败时,检查 NFC 协议版本和设备支持情况
  • 二维码扫描不识别时,确认 Scan Kit 权限已动态申请

8.2 上线清单

发布前逐项检查:

检查项 检查内容 备注
权限声明 module.json5 含 NFC 权限 ohos.permission.NFC_TAG
数据库设计 couples 表结构正确 partner_id 为外键
状态清理 退出绑定后 AppStorage 重置 避免脚棒数据残留
测试覆盖 两台设备互连测试 NFC 模拟器不支持

总结

BindRelationPage 的 787 行代码的核心亮点:

  1. 四步状态机select → sent → incoming → success 清晰分离每步的 UI 和逻辑
  2. setInterval 4 小时倒计时 :含 aboutToDisappear 清理,防内存泄漏
  3. 邀请卡仪式感设计:双头像(实线/虚线区分)+ 关系码 + 有效期,无图片资源
  4. BorderStyle.Dashed 虚线边框:对方头像用虚线暗示"等待连接"
  5. 双写策略:DB 写入 + AppStorage 同步,绑定状态即时生效

下一篇预告第96篇 SharePage------雷达图预览与 pasteboard 剪贴板分享

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

相关推荐
解局易否结局1 小时前
鸿蒙三方库生态与实战:从安装到发布全链路指南
深度学习·华为·harmonyos
绝世番茄1 小时前
鸿蒙HarmonyOS ArkTS原生学习:缩放手势实现 PinchToZoom 深度解析
学习·华为·harmonyos·鸿蒙
进击的前栈1 小时前
鸿蒙实战:19 页面全景汇总与 7 大共享设计模式回顾
华为·设计模式·harmonyos
ujainu小1 小时前
原生性能优化:6变量实现高效桥接
开发语言·华为·性能优化·harmonyos
星释2 小时前
鸿蒙智能体开发实战:27.Skill 测试、发布与管理
ui·华为·log4j·harmonyos·鸿蒙·智能体
w139548564222 小时前
鸿蒙实战:报告与雷达图 ReportDao
android·华为·harmonyos·鸿蒙系统
anling_li2 小时前
《图片华容道》一、背景设置使用指南
ui·华为·harmonyos
花开彼岸天~2 小时前
鸿蒙实战:字体令牌系统——AppFonts 与排版规范
华为·harmonyos·鸿蒙系统
xd1855785552 小时前
药箱顾问-药品说明书解读的HarmonyOS开发实践
人工智能·华为·harmonyos·鸿蒙