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

图:鸿蒙实战第94篇:CoupleHomePage 双人空间——三级降级策略与 Canvas 双折线图 运行效果截图(HarmonyOS NEXT)
CoupleHomePage 是"鹿鹿"最"温情"的页面——它展示两个人的情绪能量同步状况,以及基于双方分析记录生成的匹配度。这 857 行代码有三个特别值得学习的技术点:
- 三级降级策略:AppStorage 缓存 → DB 查询 → Mock 数据,任何一级失败自动降级
- Canvas 双折线图:两条颜色不同的折线,一条实线一条虚线(
setLineDash) - 9 模块顺序布局:从横幅到时间轴的完整双人空间 UI 设计
系列导航:93 TrendPage · 95 BindRelationPage
相关文档:Canvas API · AppStorage · RelationDao
一、三级降级策略
1.1 降级流程图
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 模块顺序
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 行代码核心收获:
- 三级降级:AppStorage 缓存 → DB 查询 → Mock 兜底,任何时机打开页面都不会白屏
setLineDash虚线:一行代码区分两条折线,无障碍友好(不依赖颜色区分)- 同步率公式:
100 - 平均绝对差值,简单直觉,无需复杂统计 - 今日任务轮换:
getDate() % 4,确定性算法,两人看到相同任务 - 9 模块单页滚动:每个模块独立 Builder,清晰分离关注点
下一篇预告:第95篇 BindRelationPage——四步状态机与邀请卡倒计时
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
- Canvas setLineDash 虚线
- linearGradient 线性渐变
- AppStorage 全局状态
- relationalStore RDB 查询
- 第48篇 EmotionTrendChart 折线图组件
- 第49篇 MatchRing 匹配度圆环
- 第69篇 线性渐变应用
- 第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 双色雷达图
本篇技术关键词:@StorageLink、MatchRing、DuetReport、RelationDao、animateTo、双人实时同步、Canvas 圆环渐变、情感化设计。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
更多推荐




所有评论(0)