鸿蒙实战:CoupleHomePage 双人空间——三级降级策略与 Canvas 双折线图

前言

在这里插入图片描述

图:鸿蒙实战第94篇:CoupleHomePage 双人空间——三级降级策略与 Canvas 双折线图 运行效果截图(HarmonyOS NEXT)

CoupleHomePage 是"鹿鹿"最"温情"的页面——它展示两个人的情绪能量同步状况,以及基于双方分析记录生成的匹配度。这 857 行代码有三个特别值得学习的技术点:

  1. 三级降级策略:AppStorage 缓存 → DB 查询 → Mock 数据,任何一级失败自动降级
  2. Canvas 双折线图:两条颜色不同的折线,一条实线一条虚线(setLineDash
  3. 9 模块顺序布局:从横幅到时间轴的完整双人空间 UI 设计

系列导航:93 TrendPage · 95 BindRelationPage

相关文档:Canvas API · AppStorage · RelationDao


一、三级降级策略

1.1 降级流程图

aboutToAppear

AppStorage
couple_bound === true?

使用 AppStorage 缓存
partner_name / couple_bound

RelationDao.findByUser
查询 DB

DB 有记录?

使用 DB 真实关系数据
同时写回 AppStorage

使用 Mock 数据
伴侣名 = 'TA'
状态 = 未绑定

渲染双人空间 UI
显示绑定引导横幅

1.2 代码实现

private async loadCoupleData(): Promise<void> {
  // 第一级:AppStorage 缓存(最快)
  const isBound = AppStorage.get<boolean>('couple_bound')
  if (isBound === true) {
    this.partnerName = AppStorage.get<string>('couple_partner_name') ?? 'TA'
    this.isBound = true
    await this.loadTrendData()
    return
  }

  // 第二级:DB 查询
  try {
    const userId = AppStorage.get<number>('user_id') ?? 1
    const relation = await RelationDao.findByUser(userId)
    if (relation) {
      this.isBound = true
      this.partnerName = relation.partner_name ?? 'TA'
      // 写回 AppStorage,下次走第一级
      AppStorage.setOrCreate<boolean>('couple_bound', true)
      AppStorage.setOrCreate<string>('couple_partner_name', this.partnerName)
      await this.loadTrendData()
      return
    }
  } catch (e) {
    hilog.error(TAG, 'RelationDao 查询失败: %{public}s', JSON.stringify(e))
  }

  // 第三级:Mock 数据(兜底)
  this.isBound = false
  this.partnerName = 'TA'
  this.generateMockTrendData()
}
级别 触发条件 数据来源 耗时
一级 AppStorage.couple_bound === true 内存 ~0ms
二级 AppStorage 无缓存 RDB 查询 ~10ms
三级 DB 无记录 / 查询失败 Mock 正弦波 ~1ms

二、Canvas 双折线图

2.1 数据结构

interface CoupleTrendPoint {
  date: string     // YYYY-MM-DD
  myScore: number  // 我的情绪分
  yourScore: number// 对方情绪分
}

2.2 双折线绘制逻辑

private drawDualTrend(): void {
  const ctx = this.canvasCtx
  const w = 340, h = 120
  const padL = 20, padR = 20, padT = 10, padB = 20
  const chartW = w - padL - padR
  const chartH = h - padT - padB

  const n = this.trendPoints.length
  if (n < 2) return

  const toX = (i: number) => padL + (i / (n - 1)) * chartW
  const toY = (score: number) => padT + chartH - ((score - 20) / 80) * chartH

  // ===== 我的折线(实线,主色 #A8907A)=====
  ctx.strokeStyle = '#A8907A'
  ctx.lineWidth = 2
  ctx.setLineDash([])  // 实线
  ctx.lineCap = 'round'
  ctx.lineJoin = 'round'
  ctx.beginPath()
  this.trendPoints.forEach((p, i) => {
    if (i === 0) ctx.moveTo(toX(0), toY(p.myScore))
    else ctx.lineTo(toX(i), toY(p.myScore))
  })
  ctx.stroke()

  // ===== 对方折线(虚线,玫瑰色 #C9A0A0)=====
  ctx.strokeStyle = '#C9A0A0'
  ctx.lineWidth = 2
  ctx.setLineDash([6, 4])  // ← 虚线:6px线段 + 4px间隙
  ctx.beginPath()
  this.trendPoints.forEach((p, i) => {
    if (i === 0) ctx.moveTo(toX(0), toY(p.yourScore))
    else ctx.lineTo(toX(i), toY(p.yourScore))
  })
  ctx.stroke()
  ctx.setLineDash([])  // 恢复实线(重要:避免影响后续绘制)

  // ===== 端点高亮(仅最后一个点)=====
  const lastI = n - 1
  const lastPoint = this.trendPoints[lastI]

  // 我的端点
  ctx.fillStyle = '#A8907A'
  ctx.beginPath()
  ctx.arc(toX(lastI), toY(lastPoint.myScore), 4, 0, Math.PI * 2)
  ctx.fill()

  // 对方端点
  ctx.fillStyle = '#C9A0A0'
  ctx.beginPath()
  ctx.arc(toX(lastI), toY(lastPoint.yourScore), 4, 0, Math.PI * 2)
  ctx.fill()
}

setLineDash([6, 4]):第一个值是线段长度(6px),第二个值是间隙长度(4px)。这是区分两条折线的最有效视觉方式,即使在黑白打印场景下也可区分。


三、同步率计算

3.1 计算公式

private calculateSyncRate(): number {
  if (this.trendPoints.length === 0) return 75  // 无数据时返回默认值

  // 取最近 14 天数据
  const recent = this.trendPoints.slice(-14)
  const avgDiff = recent.reduce((sum, p) => {
    return sum + Math.abs(p.myScore - p.yourScore)
  }, 0) / recent.length

  // 同步率 = 100 - 平均差值(差值越小,同步率越高)
  return Math.max(0, Math.min(100, Math.round(100 - avgDiff)))
}

当两人情绪分差值为 0 时同步率 100%;差值 30 时同步率 70%——直觉上符合"越相近越同步"的逻辑。


四、9 模块布局设计

4.1 模块顺序

① 三色渐变横幅
135° #F4D5C5 → WARM → ROSE
双人头像 + 情侣天数

② 统计数据行
总记录数 / 同步率 / 连续天数

③ CTA 区
今日 → 分析 按钮

④ 双折线图
Canvas 340×120
实线(我) + 虚线(TA)

⑤ 今日双人任务
4 个任务模板轮换

⑥ 合盘报告入口
→ DuetReportPage

⑦ 关系时间轴
绑定时间 + 第几天

⑧ 最近笔迹流
两人最近 3 条记录

⑨ 绑定引导(未绑定时)
→ BindRelationPage

4.2 今日任务轮换算法

private getTodayTask(): string {
  const tasks = [
    '今天写一句话给对方,用手写的方式',
    '回忆一个你们最开心的瞬间,写下来',
    '今天拍一张让你想到 TA 的照片',
    '写下今天最想对 TA 说的一句话'
  ]
  // 按当天日期取余,保证同一天看到相同任务
  const today = new Date().getDate()
  return tasks[today % tasks.length]
}

五、三色渐变横幅

Row() {
  // 左侧:双人头像 + 情侣名
  // 右侧:关系天数
}
.width('100%')
.padding(16)
.linearGradient({
  angle: 135,
  colors: [
    ['#F4D5C5', 0],      // 暖杏(我方颜色)
    [AppColors.WARM, 0.5], // 暖粉(过渡)
    [AppColors.ROSE, 1]   // 玫瑰(对方颜色)
  ]
})
.borderRadius({ topLeft: 16, topRight: 16 })

135° 渐变从左上到右下,从"我的颜色"(暖杏)过渡到"TA 的颜色"(玫瑰),视觉上暗示了两个人的"交融"。


八、注意事项与常见问题

8.1 开发注意事项

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

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

8.2 常见错误与解决方案

常见问题快速排查表:

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

九、最佳实践与性能优化

9.1 代码组织规范

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

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

9.2 性能优化要点

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

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

总结

CoupleHomePage 的 857 行代码核心收获:

  1. 三级降级:AppStorage 缓存 → DB 查询 → Mock 兜底,任何时机打开页面都不会白屏
  2. setLineDash 虚线:一行代码区分两条折线,无障碍友好(不依赖颜色区分)
  3. 同步率公式100 - 平均绝对差值,简单直觉,无需复杂统计
  4. 今日任务轮换getDate() % 4,确定性算法,两人看到相同任务
  5. 9 模块单页滚动:每个模块独立 Builder,清晰分离关注点

下一篇预告第95篇 BindRelationPage——四步状态机与邀请卡倒计时

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


相关资源:


七、CoupleHomePage 关键技术实现

7.1 双人数据的加载策略

双人页面需要同时加载自己和伴侣的数据:

private async loadCoupleData() {
  const userId = AppStorage.get<number>('currentUserId')!;
  const relation = await RelationDao.findByUser(userId);

  if (!relation) {
    // 未绑定伴侣,显示绑定引导
    this.showBindGuide = true;
    return;
  }

  // 并行加载双方最新报告
  const [myReport, partnerReport] = await Promise.all([
    ReportDao.findLatestByUser(userId),
    ReportDao.findLatestByPartnerCode(relation.partnerCode)
  ]);

  this.myRadarData = myReport?.radarData ?? null;
  this.partnerRadarData = partnerReport?.radarData ?? null;
  this.matchScore = this.calculateMatch(this.myRadarData, this.partnerRadarData);
}

7.2 匹配度计算算法

匹配度基于双方雷达数据的向量余弦相似度:

private calculateMatch(a: RadarData | null, b: RadarData | null): number {
  if (!a || !b) return 0;
  const scores1 = a.scores;
  const scores2 = b.scores;

  // 余弦相似度:点积 / (模1 × 模2)
  let dot = 0, mag1 = 0, mag2 = 0;
  for (let i = 0; i < scores1.length; i++) {
    dot += scores1[i] * scores2[i];
    mag1 += scores1[i] ** 2;
    mag2 += scores2[i] ** 2;
  }
  const similarity = dot / (Math.sqrt(mag1) * Math.sqrt(mag2));
  // 相似度映射到 0~100,但极端相似(>0.95)反而不好
  // 最理想匹配度在 0.7~0.85 之间(互补但不过于相似)
  const ideal = 0.78;
  const diff = Math.abs(similarity - ideal);
  return Math.round(Math.max(0, 100 - diff * 200));
}

7.3 双色雷达图的数据封装

CoupleHomePage 中的雷达图展示双方数据叠加:

@Builder
duetRadarChart() {
  DuetRadarChart({
    myData: {
      scores: this.myRadarData?.scores ?? [0,0,0,0,0,0],
      color: AppColors.accentMain,
      label: '我'
    },
    partnerData: {
      scores: this.partnerRadarData?.scores ?? [0,0,0,0,0,0],
      color: '#8BB4D4',  // 伴侣:蓝灰色
      label: this.partnerName
    },
    dimensions: ['情绪稳定', '自我认知', '社交倾向', '创造力', '责任感', '共情力']
  })
  .width('100%').height(280)
}

7.4 未绑定时的空状态引导

if (this.showBindGuide) {
  Column({ space: 16 }) {
    Image($r('app.media.ic_couple_empty'))
      .width(120).height(120).opacity(0.6)
    Text('还未绑定伴侣').fontSize(AppFonts.sizeLg).fontColor(AppColors.textSecondary)
    Text('通过碰一碰或分享码与伴侣建立连接').fontSize(AppFonts.sizeSm).fontColor(AppColors.textHint)
    Button('立即绑定')
      .onClick(() => router.pushUrl({ url: 'pages/BindRelationPage' }))
  }
}

7.5 伴侣关系的隐私设计

双人空间涉及他人数据,需要特别注意隐私边界:

数据维度 可见性 设计理由
雷达分数(6维) 双方互见 核心分析结果,互信分享
具体手写图片 仅本人可见 手写内容高度私密
OCR 识别文字 仅本人可见 内容可能涉及隐私
匹配度分数 双方互见 关系健康参考指标
情绪标签云 双方互见 帮助双方理解彼此

7.6 情绪同步的可视化

双方情绪趋势对比是双人空间的核心特色,通过颜色区分:

// 我的趋势线:莫兰迪棕
const MY_COLOR = AppColors.accentMain;   // '#C4956A'

// 伴侣趋势线:蓝灰
const PARTNER_COLOR = '#8BB4D4';

// 在同一个 EmotionTrendChart 中渲染双线
EmotionTrendChart({
  dataLines: [
    { data: myTrend, color: MY_COLOR, label: '我' },
    { data: partnerTrend, color: PARTNER_COLOR, label: this.partnerName }
  ]
})

产品设计感悟:CoupleHomePage 的设计核心是「共鸣感」而非「比较感」——匹配度不是竞赛分数,而是双方了解彼此的窗口。这种定位决定了整个页面的视觉风格:温暖、平静、治愈,而非刺激、竞争或评判。

延伸阅读:第95篇 BindRelationPage 关系绑定 · 第51篇 DuetReport 双色雷达图


本篇技术关键词@StorageLinkMatchRingDuetReportRelationDaoanimateTo、双人实时同步、Canvas 圆环渐变、情感化设计。

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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐