牙线 vs 牙签:用 ArkTS 构建口腔护理对比应用 —— HarmonyOS API 24 实战全记录




平台:HarmonyOS NEXT(API 24+)
一、 为什么做这个应用?
1.1 一个被忽视的日常选择题
「吃完饭用牙签还是牙线?」——这是中国数亿人每天都在做的事情,但绝大多数人从未认真思考过这个问题。
走进任何一家餐厅,收银台旁必定有牙签盒;而牙线,大多数人只在牙科诊所见过。然而口腔医学界早已形成共识:牙线是牙缝清洁的黄金标准,牙签只是临时替代品。
这种「常识」与「行为」之间的巨大鸿沟,正是这个应用的出发点。
1.2 应用的三个目标
- 教育:用数据和可视化对比,让用户直观理解两种工具的本质差异
- 纠正:打破「牙线会让牙缝变大」「牙签剔牙很正常」等常见误区
- 引导:提供科学的牙线使用指南,降低入门门槛
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 建表、插入、查询,反而增加了:
- 数据库初始化代码(约 50 行)
- 异步查询回调嵌套
- 错误处理分支(数据库损坏、权限拒绝等)
对于纯展示型数据,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 既浪费内存也增加重绘开销。
flossTotal 和 pickTotal 用 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 传函数类型可能导致编译错误。这里同样遵循该原则:label 和 section 都是简单字符串,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 上支持 animateTo 和 transition 动画。但本应用没有使用,原因:
- 内容型应用:动画对于展示对比数据没有信息增益
- 展开/收起:手风琴的展开内容通过
if条件渲染,ArkTS 的过渡动画在条件渲染组件上效果不稳定 - 性能:跳过动画可以减少 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 口腔护理核心信息
最后,作为开发者,也作为一个口腔健康知识的传播者,我想用代码之外的一句话总结:
牙线清洁的是你看不见的牙菌斑,牙签剔除的是你看得见的食物残渣。前者关乎健康,后者关乎体面。两者不是平替,而是上下位关系。
希望这个应用能帮助更多人从「饭后剔牙」走向「每日用牙线」——这一个小习惯的改变,可以大幅降低邻面龋和牙周炎的发病率。
更多推荐


所有评论(0)