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

平台:HarmonyOS NEXT(API 24+)

一、 为什么做这个应用?

1.1 一个被忽视的日常选择题

「吃完饭用牙签还是牙线?」——这是中国数亿人每天都在做的事情,但绝大多数人从未认真思考过这个问题。

走进任何一家餐厅,收银台旁必定有牙签盒;而牙线,大多数人只在牙科诊所见过。然而口腔医学界早已形成共识:牙线是牙缝清洁的黄金标准,牙签只是临时替代品

这种「常识」与「行为」之间的巨大鸿沟,正是这个应用的出发点。

1.2 应用的三个目标

  1. 教育:用数据和可视化对比,让用户直观理解两种工具的本质差异
  2. 纠正:打破「牙线会让牙缝变大」「牙签剔牙很正常」等常见误区
  3. 引导:提供科学的牙线使用指南,降低入门门槛

1.3 为什么用 HarmonyOS 做?

选择 ArkTS 而非 Flutter 或原生 Java,有三个考虑:

因素 决策
目标用户 鸿蒙生态用户,特别是中老年群体(口腔健康意识薄弱但使用鸿蒙设备)
部署成本 单页面应用,无后端依赖,安装即用
技术验证 验证 ArkTS API 24 在「数据展示型应用」上的表现

二、 口腔护理科学:对比背后的循证医学

在写代码之前,必须先搞清楚「对比什么」和「为什么这样比」。这是本应用区别于「随手写的 Demo」的核心价值。

2.1 牙菌斑生物膜

口腔中约有 700 多种细菌,它们会附着在牙齿表面形成 生物膜(Biofilm),俗称牙菌斑。牙菌斑如果不及时清除,会在 24 小时内钙化形成牙结石。

关键事实

  • 牙刷只能清洁牙齿的颊侧、舌侧和咬合面,约占牙齿表面积的 65%
  • 剩余的 35% —— 邻面(Interproximal Surface) —— 必须依靠牙缝清洁工具
  • 牙菌斑从形成到引发牙龈炎约需 48~72 小时,因此每天一次的牙缝清洁是必要的

2.2 牙线的工作原理

牙线由 扁平尼龙纤维聚四氟乙烯(PTFE) 编织而成。正确使用时:

① 取约 40cm 牙线
② 绕在双手中指
③ 拇指和食指绷紧约 2cm
④ 轻轻压入牙缝,呈「C 形」包绕牙面
⑤ 从牙龈沟底向咬合方向刮擦 4~5 次

C 形包绕是关键:牙线不是直上直下地「拉锯」,而是弯曲包裹整个牙面,这样才能刮下牙菌斑。

2.3 牙签的工作原理与隐患

牙签通常是 三角形截面 的竹木制品:

特性 后果
三角形截面 长期使用会楔开牙缝,导致牙间乳头萎缩
坚硬尖端 容易刺伤牙龈上皮,引起牙龈出血和感染
粗糙表面 无法刮除牙菌斑,只能带出大块食物
一次性使用后折断 可能残留木刺在牙龈沟内

牙签唯一合适的场景:进食后有大块纤维性食物(如牛肉丝、金针菇)嵌塞在牙缝中,用牙签轻轻挑出。之后仍应用牙线清洁该牙缝。

2.4 八大对比维度的科学依据

维度 依据来源
清洁效率 系统评价(Sälzer 2015):牙线减少邻面菌斑 44%,牙签 8%
牙龈保护 牙线可按摩牙龈乳头,促进微循环;牙签导致牙龈退缩
牙缝安全 牙线扁平设计,牙签三角形楔入力学
预防蛀牙 Hujoel 2006:牙线使邻面龋减少 40%
预防牙周炎 牙周袋细菌清除率:牙线 > 水牙线 > 牙签
便携性 牙签单手操作 vs 牙线需要双手
上手难度 学习曲线:牙线 1~2 周 vs 牙签即时
口腔异味 VSC(挥发性硫化物)减少率对比

三、 技术架构:ArkTS + API 24 兼容设计

3.1 整体架构

┌─────────────────────────────────────────┐
│              UI Layer                    │
│  @Entry @Component struct Index          │
│  ├─ TabButton (导航标签)                 │
│  ├─ CompareSection (对比视图)            │
│  ├─ TipsSection (专家建议)               │
│  └─ FaqSection (常见误区)               │
├─────────────────────────────────────────┤
│           State Layer                   │
│  @State activeSection: string           │
│  @State selectedIndex: number           │
├─────────────────────────────────────────┤
│           Data Layer                    │
│  const COMPARE_DATA: CompareItem[]      │
│  const TIPS: TipItem[]                  │
│  const FAQS: FaqItem[]                  │
├─────────────────────────────────────────┤
│          Utility Functions              │
│  calcAvg() / barColor()                 │
└─────────────────────────────────────────┘

3.2 API 24 兼容策略总表

ArkTS 特性 本项目用法 避开的原因
relationalStore 未使用 API 24 需手动建表,对于只读数据过于笨重
@ohos.data.preferences 未使用 本应用无用户数据需要持久化
setTimeout / setInterval 未使用 ArkTS 对定时器支持不稳定
@Builder 函数参数 仅传 string 传函数类型在 API 24 上可能编译失败
展开运算符 ... 未使用 ArkTS 不支持对象展开
ForEach 使用默认 key 静态数组无需 keyGenerator
Row / Column 替代 Flex Flex 部分参数在 API 24 上行为异常
Scroll ScrollDirection 构造参数 避免 .scrollable() 方法
手动千分位 不需要(无大数字) -

3.3 为什么不用数据库?

这个应用的数据特点是 全读不写

COMPARE_DATA —— 8 条 × 7 个字段,总计 56 个值
TIPS         —— 4 条 × 3 个字段,总计 12 个值
FAQS         —— 6 条 × 2 个字段,总计 12 个值

总计约 80 个静态值。放在 const 数组中,编译期即确定,运行时零 IO 开销。如果使用 relationalStore 建表、插入、查询,反而增加了:

  1. 数据库初始化代码(约 50 行)
  2. 异步查询回调嵌套
  3. 错误处理分支(数据库损坏、权限拒绝等)

对于纯展示型数据,const 数组是最佳选择。 这不是偷懒,是「奥卡姆剃刀」——如无必要,勿增实体。


四、 数据模型设计:Interface First

4.1 CompareItem —— 对比维度

interface CompareItem {
  dim: string           // 维度名称(如"清洁效率")
  flossScore: number    // 牙线评分 0-10
  pickScore: number     // 牙签评分 0-10
  flossDesc: string     // 牙线详细说明
  pickDesc: string      // 牙签详细说明
  icon: string          // Emoji 图标
}

设计考量的三个原则

原则 体现
自解释 每个字段名一看就懂,无需注释
扁平化 不嵌套对象,便于 ForEach 直接遍历
可扩展 如需增加维度,只需追加数组元素,无模板修改
为什么不用枚举?

你可能想这样设计:

enum ScoreLevel { POOR, FAIR, GOOD, EXCELLENT }

但枚举的缺点是:丢失了粒度。8 分和 9 分都属 EXCELLENT,但柱状图展示时需要精确差值。用 0~10 的数字,既支持精确展示,又可通过 >=8 等阈值分类。

4.2 TipItem —— 专家建议

interface TipItem {
  title: string       // 标题
  content: string     // 正文
  type: string        // 'do' / 'dont' / 'info'
}

type 字段驱动卡片背景色:

backgroundColor(
  tip.type === 'do' ? '#0D3320' :    // 绿色系——推荐
  tip.type === 'dont' ? '#331111' :   // 红色系——禁忌
  '#0D1A33'                           // 蓝色系——资讯
)

这是 数据驱动 UI 的典型实践:数据里包含了渲染参数,视图层用 switch/条件表达式 即可完成适配,无需写多个 Builder。

4.3 FaqItem —— 常见误区

interface FaqItem {
  question: string
  answer: string
}

最简单的结构,但内容是最复杂的。每条 answer 约 50~100 字,涵盖了牙科领域最常见的认知误区。这些内容来自:

  • 美国牙科协会(ADA)患者教育手册
  • 中华口腔医学会《牙周病防治指南》
  • 微信朋友圈百条辟谣总结(「牙线会让牙缝变大」是重灾区)

4.4 数据层的可维护性

所有数据集中在文件头部(L1~L130),与 UI 代码清晰分离。如果想更新内容,只需要:

将新数据替换对应的数组元素 → 保存 → 构建 → 部署

无需理解任何 UI 逻辑,内容运营人员(甚至 AI)即可维护。


五、 核心代码逐段解析

5.1 工具函数:calcAvg 与 barColor

function calcAvg(scores: number[]): number {
  let sum = 0
  for (let i = 0; i < scores.length; i++) {
    sum += scores[i]
  }
  return Math.round((sum / scores.length) * 10) / 10
}

为什么不用 reduce

// 理论上可以,但:
scores.reduce((a, b) => a + b, 0)  // ❌ 某些 ArkTS 版本对 reduce 支持不稳定

for 循环是所有 JavaScript 引擎最古老、最稳定的语法。在 ArkTS 这种「JavaScript 子集」环境中,保守选择更安全。这不是性能考量——8 个元素的数组 reduce 和 for 的差异可以忽略不计——而是兼容性考量。

function barColor(score: number): string {
  if (score >= 8) return '#2ECC71'   // 优秀
  if (score >= 6) return '#F1C40F'   // 良好
  if (score >= 4) return '#E67E22'   // 一般
  return '#E74C3C'                    // 较差
}

四色分级:绿 → 黄 → 橙 → 红,对应优秀/良好/一般/较差。这个分级并非随意设定:

颜色 含义 用户感知
绿色 #2ECC71 强烈推荐 👍 可以用这个
黄色 #F1C40F 可以接受 👌 凑合
橙色 #E67E22 不推荐 ⚠️ 最好换
红色 #E74C3C 避免使用 ❌ 别用这个

视觉心理学上,绿色触发「安全/通过」的直觉反应,红色触发「危险/警告」。用户无需阅读具体分数,颜色本身已经传达了立场。

5.2 @Entry @Component 结构

@Entry
@Component
struct Index {
  @State activeSection: string = 'compare'
  @State selectedIndex: number = -1

  private flossTotal: number = calcAvg(...)
  private pickTotal: number = calcAvg(...)
}
@State 的选择

只有 两个 状态变量:

变量 初始值 作用
activeSection 'compare' 控制三个 Tab 的切换
selectedIndex -1 控制列表项的展开/收起(-1 表示全部收起)

为什么其他数据不需要 @State?

因为 COMPARE_DATA、TIPS、FAQS 是 编译期常量const 数组),永不变化。@State 只用于 运行时交互状态 的追踪。ArkTS 的响应式机制会对每一个 @State 变量做依赖追踪,不必要的 @State 既浪费内存也增加重绘开销。

flossTotalpickTotal 用 private 而非 @State
private flossTotal: number = calcAvg(COMPARE_DATA.map(...))

这两个值在 struct 初始化时计算一次,之后永不改变。用 private 标记,比 @State 更语义化——告诉读者「这是常量」。

关于 .map() 的使用

COMPARE_DATA.map((c: CompareItem): number => c.flossScore) 在 ArkTS API 24 上稳定可用。但如果你的目标设备是更早期的 API 版本(如 API 9 以下),建议改用 for 循环:

function extractScores(arr: CompareItem[], key: string): number[] {
  const result: number[] = []
  for (let i = 0; i < arr.length; i++) {
    result.push(key === 'floss' ? arr[i].flossScore : arr[i].pickScore)
  }
  return result
}

不过本项目面向 API 24+,.map() 是安全的。

5.3 @Builder TabButton

@Builder
TabButton(label: string, section: string) {
  Text(label)
    .fontSize(14)
    .fontColor(this.activeSection === section ? '#FFFFFF' : '#8899AA')
    .padding({ left: 16, right: 16, top: 6, bottom: 6 })
    .backgroundColor(this.activeSection === section ? '#2ECC71' : '#1A2A44')
    .borderRadius(16)
    .onClick(() => {
      this.activeSection = section
      this.selectedIndex = -1
    })
}

为什么 @Builder 的参数仅限于 string?

在个税计算器那篇文章中我曾指出,@Builder 传函数类型可能导致编译错误。这里同样遵循该原则:labelsection 都是简单字符串,ArkTS 编译器和运行时都能正确处理。

点击时的 this.selectedIndex = -1:切换 Tab 时,统一收起所有展开项。如果不重置,用户从 FAQ Tab 切换到 Compare Tab 后,可能看到 FAQ 的某个答案意外展开着。

5.4 CompareSection 中的综合评分卡片

@Builder
ScoreSummary() {
  Column() {
    Text('🏆 综合评分').fontSize(14).fontColor('#AABBCC')

    Row({ space: 24 }) {
      // 牙线
      Column({ space: 4 }) {
        Text('🪥 牙线').fontSize(16).fontColor('#FFFFFF')
        Text(this.flossTotal.toString())
          .fontSize(36).fontColor('#2ECC71')
        Text('/ 10').fontSize(12).fontColor('#556677')
      }

      // VS
      Text('VS').fontSize(20).fontColor('#556677')

      // 牙签
      Column({ space: 4 }) {
        Text('🪚 牙签').fontSize(16).fontColor('#FFFFFF')
        Text(this.pickTotal.toString())
          .fontSize(36).fontColor('#E67E22')
        Text('/ 10').fontSize(12).fontColor('#556677')
      }
    }
    // ...居中、间距设置
  }
}

为什么分数用 .toString() 而不是模板字符串?

Text(`${this.flossTotal}`)  // 可行,但 toString() 更明确
Text(this.flossTotal.toString())  // ✅ 显式转换

在 ArkTS 中,Text() 组件接受 string | Resource | number 类型。直接传 number 也可以:

Text(this.flossTotal)  // 也 OK,ArkTS 会自动调用 toString

但显式调用 toString() 让开发者自己清楚「这里在显示一个数字字符串」,可读性更好。

VS 的样式设计

Text('VS').fontSize(20).fontColor('#556677')——灰色调、不大不小,既不喧宾夺主,又清晰表达了对比关系。

5.5 CompareRow —— 柱状条实现

这是整个应用中 最值得细看 的一段代码。它用纯 Row/Column 实现了横向柱状图,没有使用 Canvas、Chart 组件或第三方库。

Row() {
  Text('')
    .width((item.flossScore / 10) * 100 + '%')
    .height(8)
    .backgroundColor(barColor(item.flossScore))
    .borderRadius(4)
}
.width('100%')
.height(8)
.backgroundColor('#1A2A44')
.borderRadius(4)

原理

外层容器:宽 100%,深色背景(#1A2A44),高 8px,圆角 4px
内层填充:宽 = (score / 10) × 100%,有色背景,高 8px,圆角 4px

当 score = 9 时,填充条宽度为 90%;score = 3 时,宽度为 30%。这种「容器 + 填充」的双层结构,是用布局引擎模拟柱状图的经典模式。

为什么高度用像素值而非百分比?

柱状条是固定高度的视觉元素。height(8) 表示 8 个逻辑像素,无论在哪种屏幕密度下都保持一致的视觉比例。如果用百分比,高度会随父容器高度变化,导致不同设备上柱条粗细不一。

圆角的细节:内外层都有 borderRadius(4),内层圆角 4px + 外层圆角 4px = 内层填满外层时,整体呈现 4px 圆角。如果外层圆角 > 内层圆角,未被填满的部分会出现「直角出框」的视觉瑕疵。

5.6 展开/收起交互

.onClick(() => {
  if (this.selectedIndex === idx) {
    this.selectedIndex = -1
  } else {
    this.selectedIndex = idx
  }
})

这是一个精简的 手风琴(Accordion) 实现:

  • 点击已展开项 → 收起(设为 -1)
  • 点击其他项 → 展开该项(设为对应 index)

-1 是一个巧妙的「哨兵值」。因为数组索引从 0 开始,-1 永远不会等于任何有效索引,因此 this.selectedIndex === idx 永远为 false,所有项都收起。

为什么不直接用 boolean 数组?
@State expanded: boolean[] = [false, false, ...]  // 更灵活但更复杂

因为本应用只需要 同时展开一个 项,用单个 index 即可跟踪。如果需要同时展开多个(如 FAQ 全部展开),再用 boolean 数组。

5.7 TipsSection —— 卡片式布局

@Builder
TipsSection() {
  Column() {
    Text('💡 专家建议')
      .fontSize(16).fontColor('#CCDDEE').fontWeight(FontWeight.Medium)

    ForEach(TIPS, (tip: TipItem) => {
      Column() {
        Text(tip.title).fontSize(14).fontColor('#FFFFFF')
        Text(tip.content).fontSize(12).fontColor('#AABBCC')
          .lineHeight(18)
      }
      .padding(14)
      .backgroundColor(
        tip.type === 'do' ? '#0D3320' :
        tip.type === 'dont' ? '#331111' :
        '#0D1A33'
      )
      .borderRadius(10)
      .margin({ bottom: 10 })
    })
  }
}

数据驱动的卡片颜色#0D3320(深绿色背景)代表「推荐行为」,#331111(深红色背景)代表「禁忌行为」,#0D1A33(标准深蓝)代表「资讯信息」。用户即使不读文字,只看颜色也能区分建议的性质。

为什么不用圆角边框?

.border({ width: 1, color: '#2A3A55' })  // ❌ 增加了视觉噪音

卡片本身用背景色区分已经足够,加边框反而让界面更杂乱。在深色主题下,干净的分块 + 留白通常比边框更优雅。

5.8 FaqSection —— 问答式展开

ForEach(FAQS, (faq: FaqItem, idx: number) => {
  Column() {
    Row() {
      Text('Q' + (idx + 1) + '. ').fontColor('#2ECC71').fontWeight(FontWeight.Bold)
      Text(faq.question).fontColor('#FFFFFF')
    }

    if (this.selectedIndex === idx) {
      // 分隔线
      Text('').width('100%').height(1).backgroundColor('#2A3A55')
      // 答案
      Row() {
        Text('A. ').fontColor('#E67E22')
        Text(faq.answer).fontColor('#AABBCC').lineHeight(18)
      }
    }

    // 展开/收起提示
    Text(this.selectedIndex === idx ? '▲ 收起' : '▼ 展开')
      .fontSize(11).fontColor('#6688AA')
  }
  .onClick(() => {
    if (this.selectedIndex === idx) {
      this.selectedIndex = -1
    } else {
      this.selectedIndex = idx
    }
  })
})

设计细节

  • 问题以 Q1. Q2. 编号,牙绿色(#2ECC71),清晰标识
  • 展开内容前有 1px 分隔线,视觉上区分问题和答案
  • 答案前有 A. 标记,橙色(#E67E22),快速定位
  • 右上角始终显示「▼ 展开」或「▲ 收起」提示,降低用户认知负担
为什么选中状态复用 selectedIndex 而不独立?

FAQ 和对比列表共用同一个 selectedIndex,这乍看有问题——在 FAQ Tab 点开第 3 项,切换到 Compare Tab 后会不会也展开第 3 项?

答案是:不会。 因为切换 Tab 时,onClick 中设置了 this.selectedIndex = -1,所有展开项都会收起。不同 Tab 之间不存在状态交叉。

但如果用户希望在切换 Tab 后保持之前打开的项,那就需要各自独立维护状态:

@State compareIndex: number = -1
@State faqIndex: number = -1

不过对于这个应用,切换 Tab 时收起一切更符合直觉,所以复用一个状态就足够了。


六、 UI/UX 设计与深色主题

6.1 配色系统

背景层       #0A1628    最深夜空蓝
卡片层       #111D33    稍亮蓝黑
输入/活动层   #0D1A33    中等蓝黑
主色调       #2ECC71    翡翠绿(好/推荐)
警告色       #E74C3C    红色(差/避免)
过橙色       #E67E22    橙色(中等/一般)
黄色         #F1C40F    黄色(尚可/提示)
灰蓝文字     #AABBCC    辅助文本
深灰文字     #8899AA    次要文本
最灰文字     #556677    底部说明

6.2 字号层级

用途 大小 颜色 权重
应用标题 22px #FFFFFF Bold
Tab 文字 14px 视选中状态变
维度名称 14px #CCDDEE Medium
分数大号 36px 绿/橙 Bold
辅助描述 11~12px #AABBCC Normal
底部说明 10px #556677 Normal

6.3 布局策略

三栏 Tab 导航Row + 三个 Text 组件模拟标签页,borderRadius(16) 做成药丸形状。选中背景色切换,无动画——维持 API 24 兼容。

Scroll 纵向滚动:最外层 Column 外包裹 Scroll,开启 .scrollable(ScrollDirection.Vertical),确保内容在 6 英寸以下屏幕可完整浏览。

卡片间距:卡片之间使用 margin({ bottom: 10 }),保持呼吸感。卡片内使用 padding(14),文字不贴边。

6.4 为什么没有动画?

ArkTS 在 API 24 上支持 animateTotransition 动画。但本应用没有使用,原因:

  1. 内容型应用:动画对于展示对比数据没有信息增益
  2. 展开/收起:手风琴的展开内容通过 if 条件渲染,ArkTS 的过渡动画在条件渲染组件上效果不稳定
  3. 性能:跳过动画可以减少 50% 以上的布局计算,在低端鸿蒙设备上尤为重要

不要为了炫技而加动画。每帧 16ms 的渲染预算,应该留给用户真正关心的事情——内容。


七、 ArkTS API 24 开发避坑总结

经过三个应用(图书阅读记录 → 个税计算器 → 牙线对比)的实战,以下是最有价值的经验:

7.1 条件渲染的坑

// ✅ 正确:用 if 包裹整块组件
if (this.selectedIndex === idx) {
  Column() {
    Text('详情内容')
  }
}

// ❌ 错误:在三元表达式中使用 Column
this.selectedIndex === idx ? Column() { Text('') } : Text('')  // 编译报错

ArkTS 的编译器和渲染管线要求 if 语句在组件树的「表达式位置」使用。三元表达式虽然语法正确,但在某些版本的编译器中会报 Unexpected token

7.2 ForEach 的 keyGenerator

// 静态数组——可以省略 keyGenerator
ForEach(COMPARE_DATA, (item: CompareItem) => { ... })

// 动态数组——必须提供 keyGenerator
@State items: Item[] = []
ForEach(this.items, (item: Item) => { ... }, (item: Item) => item.id)

省略 keyGenerator 时,ArkTS 使用索引作为默认 key。对于静态数组,索引不会变化,所以安全。但如果数组元素会增删或重排,必须提供稳定的 key。

7.3 @State 的异步更新

// 以下写法在 ArkTS 中可能不会立即触发 UI 刷新
this.selectedIndex = idx
someExpensiveOperation()  // 如果这个操作阻塞了 UI 线程,刷新会被推迟

ArkTS 的 @State 更新是 异步批量 的。多个 @State 修改会在同一帧中合并刷新。如果你需要在 @State 更新后立即读取 DOM 状态(旧版 ArkUI 中的 $element),注意可能读到旧值。

7.4 动画与过渡

API 24 的 animateTo 有一些注意事项:

animateTo({ duration: 300 }, () => {
  this.selectedIndex = idx  // 在这个回调中修改 @State
})
  • 回调中只能修改 @State 变量,不能进行计算密集型操作
  • 动画时长建议 200~400ms,过短用户看不清过渡,过长显得拖沓
  • 对于 if 条件渲染的组件,动画可能不生效——因为组件是被「创建/销毁」而非「移动/缩放」

7.5 颜色与主题

// ✅ 直接用十六进制字符串
.backgroundColor('#0A1628')

// ⚠️ 避免用 Color 枚举
.backgroundColor(Color.Black)  // 可用,但灵活性差

直接使用十六进制字符串更灵活,不需要导入 Color 枚举,且支持完整的 1600 万色。

7.6 Resource 与 string

Text('你好')                    // ✅ 直接字符串
Text($r('app.string.hello'))    // 多语言资源,需要 resource 目录

对于单语言应用,直接使用字符串更高效。使用 $r() 资源引用适合需要多语言本地化的生产级应用。


八、 扩展思路:生产级增强

8.1 数据层增强

如果要将这个应用部署到应用市场,以下增强是有价值的:

interface ExtendedCompareItem extends CompareItem {
  source: string        // 数据来源(PubMed 链接等)
  year: number          // 研究发表年份
  sampleSize: number    // 研究样本量
  conflict: string      // 争议观点(如果有)
}

8.2 个性化推荐

根据用户的选择提供个性化建议:

// 用户交互历史
@State userHistory: {
  usedFlossBefore: boolean
  hasGumBleeding: boolean
  hasBraces: boolean
} = { usedFlossBefore: false, hasGumBleeding: false, hasBraces: false }

根据这三个字段,可以给出不同级别的推荐:

场景 建议
没用过牙线 + 牙龈出血 先从牙线棒开始,每天一次,坚持两周
没用过牙线 + 无出血 从水牙线入门,逐步过渡到传统牙线
正畸(牙套) 推荐水牙线 + 超级牙线(Super Floss)

8.3 图表增强

当前用 Row 模拟柱状条。如果 API 24 支持 Canvas,可以绘制更精美的雷达图:

// 思路:使用 Canvas 绘制
Canvas(this.context)
  .width('100%')
  .height(200)
  .onDraw((ctx) => {
    // 绘制八边形雷达图
    // 牙线得分连线(绿色)
    // 牙签得分连线(橙色)
  })

但请注意:Canvas 在 ArkTS 中的 API 与标准 HTML Canvas 有差异,onDraw 回调的 CanvasRenderingContext2D 对象方法有限。建议在真机上测试后再决定是否使用。

8.4 接入真实 API

  • 接入 丁香医生腾讯医典 的口腔科普文章(需申请 API Key)
  • 接入 京东健康 商品推荐(牙线品牌对比、购买链接)
  • 接入 附近牙科诊所 地图(用户需要专业洁牙时的转诊入口)

九、 总结

9.1 项目成就

指标 数值
代码行数 492 行
UI 组件 4 个 @Builder
数据条目 8 个对比维度 + 4 条建议 + 6 条 FAQ
对比维度 8 个科学依据维度
API 兼容 API 24+,零外部依赖
文件大小 ~12 KB

9.2 技术要点回顾

知识点 本应用实践
Interface 设计 扁平化、自解释、可扩展
@State 管理 最小化状态变量,用哨兵值 -1 控制展开
数据驱动 UI type 字段驱动卡片颜色
纯组件柱状图 Row 嵌套 + 百分比宽度模拟
手风琴交互 单 index 追踪,点击切换展开/收起
条件渲染 if 包裹组件块,避免三元表达式
Scroll 滚动 外层 Scroll + ScrollDirection 参数
深色主题 统一配色系统,字号层级清晰

9.3 口腔护理核心信息

最后,作为开发者,也作为一个口腔健康知识的传播者,我想用代码之外的一句话总结:

牙线清洁的是你看不见的牙菌斑,牙签剔除的是你看得见的食物残渣。前者关乎健康,后者关乎体面。两者不是平替,而是上下位关系。

希望这个应用能帮助更多人从「饭后剔牙」走向「每日用牙线」——这一个小习惯的改变,可以大幅降低邻面龋和牙周炎的发病率。

Logo

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

更多推荐