# HarmonyOS NEXT 亲戚称呼计算器应用开发实战 —— 基于 ArkTS 的中国家族关系树实现


开发环境: DevEco Studio / HarmonyOS NEXT 6.1.1(API 24)
开发语言: ArkTS(基于 TypeScript 的鸿蒙原生语言)
工程模型: Stage 模型(单 HAP 架构)
核心数据结构: 多叉家族关系树(递归节点嵌套)
一、引言
中国家族关系(亲属称谓)是中华文化中最精妙也最复杂的组成部分之一。一个"表姐"和一个"堂姐"虽然都是姐姐辈的女性亲属,但在中国传统宗法制度中有着截然不同的含义:堂亲是同姓(父系),表亲是异姓(母系)。在社交场合用错称谓,轻则尴尬,重则失礼。
对于年轻一代来说,搞清楚复杂的亲戚关系并不是一件容易的事。"爸爸的哥哥"叫伯父,"妈妈的哥哥"叫舅舅,"爸爸的姐姐"叫姑妈,"妈妈的姐姐"叫姨妈——这些还只是第一层。如果再往下追问,"伯父的儿子"和"舅舅的儿子"分别该叫什么?很多人就需要停下来想一想了。
本文将通过 HarmonyOS NEXT 上的 ArkTS 语言,构建一个亲戚称呼计算器应用。该应用以"我"为起点,用户通过逐步选择家庭关系(如"父亲→哥哥"),应用自动计算出正确的家族称谓(如"伯父"),并显示完整的辈分标签(长辈/平辈/晚辈/姻亲)。
1.1 为什么选择 ArkTS 构建此应用
ArkTS 作为鸿蒙原生的声明式 UI 语言,其组件化、状态驱动、构建器模式等特性,非常适合实现树形数据结构的可视化导航:
- 组件化:每个 UI 片段(面包屑、名称显示、按钮网格)可以独立为
@Builder,逻辑清晰 - 状态驱动:
@State装饰器自动追踪状态变更并更新 UI,路径导航逻辑只需修改数据 - 递归数据结构:ArkTS 的接口和对象系统天然支持树形结构的递归定义
- Flex 弹性布局:按钮区域的自动换行(
FlexWrap.Wrap)完美适配不同数量的关系选项
1.2 应用预览
用户打开应用后的操作流程:
初始状态:显示 "我"(自己)
↓ 点击 "父亲"
显示 "爸爸"(长辈),路径:我 › 父亲
↓ 点击 "哥哥"
显示 "伯父"(长辈),路径:我 › 父亲 › 哥哥
↓ 点击 "儿子"
显示 "堂兄弟"(平辈),路径:我 › 父亲 › 哥哥 › 儿子
↓ 点击 "← 上一步"
返回 "伯父"
↓ 点击 "↺ 重新开始"
回到初始状态
二、HarmonyOS NEXT 项目工程回顾
2.1 项目全貌
在编写亲戚称呼计算器之前,我们需要回顾 HarmonyOS NEXT 项目的基本结构:
apptools/
├── AppScope/ # 应用全局配置
│ ├── app.json5 # 应用名、版本等
│ └── resources/
├── entry/ # HAP 模块
│ ├── src/main/ets/pages/ # 页面代码
│ │ ├── Index.ets # 默认首页
│ │ ├── index1.ets # 计算器
│ │ └── index3.ets # 亲戚称呼计算器(本文)
│ ├── src/main/resources/ # 资源文件
│ ├── src/main/module.json5 # 模块配置
│ └── build-profile.json5 # 构建配置
├── hvigor/ # 构建工具
├── build-profile.json5 # 应用级构建配置
└── oh-package.json5 # OHPM 依赖管理
2.2 页面路由注册
每个新增的页面必须在 main_pages.json 中注册,才能被应用访问:
{
"src": [
"pages/Index",
"pages/index1",
"pages/index3"
]
}
这个文件定义了一个有序的路由表。pages/index3 对应 entry/src/main/ets/pages/index3.ets 文件。文件名需要与注册名完全一致(包括大小写)。
2.3 Stage 模型的 Ability
应用入口 EntryAbility 负责页面加载:
export default class EntryAbility extends UIAbility {
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => { /* ... */ });
}
}
loadContent 加载的是 main_pages.json 中 src 数组的第一个页面。如果想要将亲戚称呼计算器设为默认启动页,只需将数组顺序调整为 ["pages/index3", "pages/Index", "pages/index1"] 即可。
三、数据模型设计:家族关系树
3.1 关系树的需求分析
中国家族关系本质上是一棵多叉树。每个节点代表一个相对称呼,每个节点有若干子关系(relations),指向该人物的亲属。
以"我"为根节点,父节点是什么样的关系呢?
我
┌───┬───┼───┬───┬───┬───┬───┬───┐
父亲 母亲 哥哥 弟弟 姐姐 妹妹 丈夫 妻子 儿子 女儿
│ │ │ │
爸爸 妈妈 老公 老婆
每个节点(如"爸爸")又有自己的关系:
爸爸
┌───┼───┬───┬───┬───┬───┐
父亲 母亲 哥哥 弟弟 姐姐 妹妹 儿子 女儿
│ │ │ │ │ │ │ │
爷爷 奶奶 伯父 叔叔 姑妈 姑姑 哥哥 姐姐
层层递归,形成一个庞大的多叉树。
3.2 ArkTS 接口定义
根据上述需求,我们定义两个接口:
interface RelationEntry {
relation: string; // 与当前人物的关系描述,如 "父亲"、"哥哥"
node: RelationTreeNode; // 该关系指向的目标人物节点
}
interface RelationTreeNode {
name: string; // 该人物的称呼,如 "爸爸"、"伯父"
relations: RelationEntry[]; // 该人物拥有的子关系列表
}
设计要点:
RelationTreeNode是递归定义的:它内部的relations数组中的每个RelationEntry.node又是一个RelationTreeNodename字段存储的是从当前人物角度对该节点的称呼。例如,从"我"的角度,“父亲的哥哥"对应的节点name是"伯父”relations字段存储的是该人物与其亲属的关系。例如 “伯父” 的relations中包含{ relation: '儿子', node: Node('堂兄弟') },表示"伯父的儿子是堂兄弟"
3.3 树节点构建函数
我们通过一个 buildFamilyTree() 函数来构建整棵树。函数采用自底向上的构建方式:先创建最底层的叶子节点(如"曾祖父"、“伯祖父"等),然后逐步构建上层节点,最后组装根节点"我”。
function buildFamilyTree(): RelationTreeNode {
// 第四层:曾祖辈(叶子节点,没有子关系)
const zengzufu: RelationTreeNode = { name: '曾祖父', relations: [] };
const zengzumu: RelationTreeNode = { name: '曾祖母', relations: [] };
const waizengzufu: RelationTreeNode = { name: '外曾祖父', relations: [] };
const waizengzumu: RelationTreeNode = { name: '外曾祖母', relations: [] };
// 第三层:祖辈的兄弟姐妹(也是叶子节点)
const zufuXiongdi: RelationTreeNode = { name: '伯祖父/叔祖父', relations: [] };
const zufuJiemei: RelationTreeNode = { name: '姑祖母', relations: [] };
// 第三层:爷爷
const yeye: RelationTreeNode = {
name: '爷爷',
relations: [
{ relation: '父亲', node: zengzufu },
{ relation: '母亲', node: zengzumu },
{ relation: '哥哥', node: zufuXiongdi },
{ relation: '弟弟', node: zufuXiongdi },
// ...
]
};
// ... 逐层构建 ...
// 根节点:我
const root: RelationTreeNode = {
name: '我',
relations: [
{ relation: '父亲', node: baba },
{ relation: '母亲', node: mama },
{ relation: '哥哥', node: gege },
// ...
]
};
return root;
}
这种构建方式有几个优点:
- 引用共享:多个节点可以引用同一个子节点。例如"爸爸"的"父亲"和"伯父"的"父亲"都指向同一个"爷爷"节点,确保了数据一致性
- 避免循环引用:家族关系是单向的(从长辈到晚辈),不会出现循环,适合于树结构
- 构建顺序清晰:从叶子到根,不会出现引用未定义节点的问题
3.4 关系树的覆盖面
我们的关系树共覆盖了 5 代、超过 60 种亲属关系,完整涵盖了:
| 辈分类别 | 代表关系 | 数量 |
|---|---|---|
| 曾祖辈 | 曾祖父、曾祖母、外曾祖父、外曾祖母 | 4+ |
| 祖辈 | 爷爷、奶奶、外公、外婆、伯祖父、姑祖母、舅祖父、姨祖母 | 8+ |
| 父辈 | 爸爸、妈妈、伯父、叔叔、姑妈、姑姑、舅舅、姨妈、阿姨 | 10+ |
| 姻亲·父辈 | 伯母、婶婶、姑父、舅妈、姨父 | 5 |
| 平辈 | 哥哥、弟弟、姐姐、妹妹、堂兄弟、堂姐妹、表兄弟、表姐妹 | 8+ |
| 配偶 | 老公、老婆 | 2 |
| 配偶姻亲 | 大伯子、小叔子、大姑子、小姑子、大舅子、小舅子、大姨子、小姨子 | 8 |
| 配偶父母 | 公公、婆婆、岳父、岳母 | 4 |
| 晚辈 | 儿子、女儿、侄子、侄女、外甥、外甥女、儿媳、女婿 | 8 |
| 孙辈 | 孙子、孙女、外孙、外孙女、侄孙 | 5 |
四、UI 界面设计与实现
4.1 整体布局结构
应用界面采用自上而下的垂直布局(Column):
┌──────────────────────────────────┐
│ 🧑🤝🧑 亲戚称呼计算器 ← 标题栏 │
├──────────────────────────────────┤
│ 我 › 父亲 › 哥哥 ← 面包屑(可横向滚动) │
├──────────────────────────────────┤
│ │
│ 伯父 ← 称呼(48px 白色大字) │
│ 我的父亲的哥哥 ← 路径描述(灰色小字) │
│ (长辈) ← 辈分标签 │
│ │
├──────────────────────────────────┤
│ 选择下一步关系 │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 妻子 │ │ 儿子 │ │ 女儿 │ ← 关系按钮(Flex 换行) │
│ └──────┘ └──────┘ └──────┘ │
│ ┌──────┐ ┌──────┐ │
│ │ 父亲 │ │ 母亲 │ │
│ └──────┘ └──────┘ │
├──────────────────────────────────┤
│ ← 上一步 ↺ 重新开始 │
└──────────────────────────────────┘
4.2 完整的 build() 方法
build() {
Column() {
// 顶部标题栏
Row() {
Text("🧑🤝🧑 亲戚称呼计算器")
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
}
.width('100%')
.padding({ top: 16, bottom: 8, left: 20 })
.alignItems(VerticalAlign.Center)
Divider().height(0.5).color('#38383A').width('94%')
// 关系路径面包屑
this.buildBreadcrumb()
Divider().height(0.5).color('#38383A').width('94%')
// 当前称呼显示
this.buildNameDisplay()
Divider().height(0.5).color('#38383A').width('94%')
// 可选关系按钮区域
this.buildRelationButtons()
// 底部操作按钮
this.buildActionButtons()
}
.width('100%')
.height('100%')
.backgroundColor('#1C1C1E')
}
整个 UI 由六个 @Builder 方法和若干个 Divider 分隔线组成。Column 作为根容器,子组件按顺序从上到下排列。
4.3 主题与色彩设计
应用延续了深色主题风格,与之前的计算器应用保持一致:
| UI 元素 | 色值 | 用途 |
|---|---|---|
| 应用背景 | #1C1C1E |
深色背景 |
| 标题/称呼文字 | Color.White |
高对比度主内容 |
| 面包屑当前项 | #FF9500(橙色) |
高亮路径终点 |
| 面包屑/描述文字 | #8E8E93(灰色) |
次要信息 |
| 辈分标签 | #5E5E61(暗灰) |
最弱视觉层级 |
| 分割线 | #38383A |
区域分隔 |
| 按钮背景 | #2C2C2E(深灰) |
可交互元素 |
| 重置按钮文字 | #FF9500(橙色) |
突出强调 |
这种配色方案遵循了视觉层级原则:最重要的信息(称呼)通过最大字号和最亮颜色凸显;次要信息(路径描述、辈分标签)逐渐减弱;可操作元素(按钮)通过独立背景色与只读内容区分。
4.4 面包屑导航(Breadcrumb)
面包屑是用户定位当前路径的关键视觉组件:
@Builder
buildBreadcrumb() {
Scroll() {
Row() {
// 起点:"我"
Text("我")
.fontSize(16)
.fontColor('#8E8E93')
.padding({ left: 6, right: 6 })
if (this.pathSteps.length > 0) {
Text("›") // 分隔符
.fontSize(20)
.fontColor('#8E8E93')
.padding({ left: 2, right: 2 })
ForEach(this.pathSteps, (step: string, index: number) => {
Text(step)
.fontSize(16)
.fontColor(index === this.pathSteps.length - 1 ? '#FF9500' : '#8E8E93')
.fontWeight(index === this.pathSteps.length - 1
? FontWeight.Bold : FontWeight.Regular)
.padding({ left: 6, right: 6 })
// 非最后一项后面加分隔符
if (index < this.pathSteps.length - 1) {
Text("›")
.fontSize(20)
.fontColor('#8E8E93')
.padding({ left: 2, right: 2 })
}
}, (step: string, index: number) => step + index)
}
}
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
}
.width('100%')
.scrollable(ScrollDirection.Horizontal)
}
设计细节:
- 使用
Scroll包裹,当路径过长时支持横向滚动 - 分隔符使用 Unicode 字符
›(U+203A),而非图片资源,避免资源依赖 - 最后一项使用橙色(
#FF9500)加粗,表示当前位置 - 其他项使用灰色,
ForEach的index参数用于区分不同位置
4.5 称呼显示区
称呼显示区是用户最关注的核心内容区域:
@Builder
buildNameDisplay() {
Column() {
// 称呼(大号白色字)
Text(this.currentNode.name)
.fontSize(48)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 4 })
// 路径描述(仅在有路径时显示)
if (this.pathSteps.length > 0) {
Text(this.getPathDescription())
.fontSize(16)
.fontColor('#8E8E93')
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 4 })
}
// 辈分标签
Text(this.getRelationshipTag())
.fontSize(14)
.fontColor('#5E5E61')
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 2, bottom: 8 })
}
.width('100%')
.padding(16)
}
三层信息结构:
| 层级 | 内容 | 字体 | 颜色 | 作用 |
|---|---|---|---|---|
| 第一层 | 称呼(如"伯父") | 48px 加粗 | 白色 | 核心输出,一眼可见 |
| 第二层 | 路径描述(如"我的父亲的哥哥") | 16px 常规 | 灰色 | 解释为什么是"伯父" |
| 第三层 | 辈分标签(如"(长辈)") | 14px 常规 | 暗灰 | 补充分类信息 |
其中 getPathDescription() 方法将路径数组转换为人类可读的文字:
getPathDescription(): string {
if (this.pathSteps.length === 0) return '';
return "我" + this.pathSteps.map(s => "的" + s).join("");
}
例如 pathSteps = ['父亲', '哥哥'] → "我的父亲的哥哥"。
4.6 关系按钮区域
按钮区域使用 Flex 布局实现自动换行,适配不同数量的关系选择:
@Builder
buildRelationButtons() {
Column() {
Text("选择下一步关系")
.fontSize(15)
.fontColor('#8E8E93')
.width('100%')
.padding({ left: 20, bottom: 8 })
if (this.currentNode.relations.length === 0) {
// 无更多子关系:显示终点提示
Column() {
Text("✓ 已到最末端,以上是最终称呼")
.fontSize(14)
.fontColor('#5E5E61')
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 16, bottom: 16 })
}
.width('100%')
} else {
// 有可用关系:Flex 自动换行排列按钮
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
ForEach(this.currentNode.relations, (entry: RelationEntry) => {
this.buildRelationButton(entry)
}, (entry: RelationEntry) => entry.relation)
}
.width('100%')
.padding({ left: 12, right: 12 })
}
}
.width('100%')
.layoutWeight(1)
.padding({ top: 12 })
}
关键技术点:
FlexWrap.Wrap:当一行放不下更多按钮时自动换行,无需手动计算行数layoutWeight(1):让按钮区域占据剩余的所有垂直空间- 叶子节点(
relations.length === 0)显示完成提示,表示已到最末端
单个按钮的构建方法:
@Builder
buildRelationButton(entry: RelationEntry) {
Button(entry.relation)
.height(56)
.padding({ left: 20, right: 20 })
.borderRadius(28)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.backgroundColor('#2C2C2E')
.fontColor(Color.White)
.margin({ left: 5, right: 5, bottom: 10 })
.onClick(() => {
this.navigateTo(entry);
})
}
按钮采用胶囊形状(borderRadius: 28),与 height: 56 配合实现完美半圆两端。内边距 padding 确保了文字不会被裁剪。
4.7 底部操作按钮
底部提供两个导航按钮:
@Builder
buildActionButtons() {
Row() {
Button("← 上一步")
.height(48)
.borderRadius(24)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.backgroundColor('#2C2C2E')
.fontColor(Color.White)
.layoutWeight(1)
.margin({ right: 6 })
.enabled(this.pathSteps.length > 0) // 无路径时禁用
.onClick(() => { this.goBack(); })
Button("↺ 重新开始")
.height(48)
.borderRadius(24)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.backgroundColor('#3A3A3C')
.fontColor('#FF9500')
.layoutWeight(1)
.margin({ left: 6 })
.onClick(() => { this.resetAll(); })
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
}
交互细节:
- "上一步"按钮使用
.enabled()属性:当pathSteps.length === 0(即在根节点"我"处)时自动灰化禁用 - "重新开始"按钮始终可用,点击后重置所有状态回到起点
- 两个按钮各占据 50% 宽度(
layoutWeight(1))
五、状态管理与导航逻辑
5.1 状态变量设计
@Component
struct FamilyRelationApp {
@State pathSteps: string[] = []; // 路径数组,如 ['父亲', '哥哥']
private currentNode: RelationTreeNode = familyTree; // 当前节点
private nodeStack: RelationTreeNode[] = []; // 历史节点栈
}
@State vs 普通成员变量:
| 变量 | 装饰器 | 变更是否触发 UI 重绘 | 用途 |
|---|---|---|---|
pathSteps |
@State |
✅ 是 | 面包屑和路径描述需要随路径更新 |
currentNode |
无 | ❌ 否 | 内部导航状态,UI 通过读取 currentNode.name 间接依赖 |
nodeStack |
无 | ❌ 否 | 纯内部数据,不需要 UI 感知 |
虽然 currentNode 没有加 @State,但当它被修改后,UI 中读取 this.currentNode.name 和 this.currentNode.relations 的地方并不会自动刷新。那为什么能正常工作?
答案在于:虽然 currentNode 本身不是响应式的,但 pathSteps 是响应式的。每次 navigateTo 或 goBack 都会修改 pathSteps(@State 变量),这个修改会触发整个组件的 build() 重新执行。在重绘时,UI 会重新读取 currentNode 的最新值,间接实现了更新。
这是一种隐式依赖的设计模式——通过修改一个 @State 变量来触发 UI 刷新,而不需要所有相关变量都是响应式的。这有助于减少不必要的状态管理开销。
5.2 前进导航(navigateTo)
navigateTo(entry: RelationEntry) {
this.nodeStack.push(this.currentNode); // 压栈:保存当前节点
this.currentNode = entry.node; // 更新当前节点为目标节点
this.pathSteps = [...this.pathSteps, entry.relation]; // 追加路径
}
前进操作三部曲:
- 将当前节点压入
nodeStack历史栈,为"上一步"做准备 - 将
currentNode更新为用户选择的目标节点 - 将选择的关系描述追加到
pathSteps中
注意 pathSteps 使用了扩展运算符创建新数组([...this.pathSteps, ...]),而不是 .push() 修改原数组。这是因为 @State 需要通过引用变更来检测数组变化——如果是修改原数组内容,ArkTS 无法感知。
5.3 后退导航(goBack)
goBack() {
if (this.pathSteps.length === 0 || this.nodeStack.length === 0) {
return; // 已在根节点,无法后退
}
this.currentNode = this.nodeStack.pop()!; // 出栈:恢复上一个节点
this.pathSteps = this.pathSteps.slice(0, -1); // 删除最后一步
}
后退操作与前进对称:
- 从
nodeStack弹出上一个节点,恢复为当前节点 - 使用
slice(0, -1)创建新数组(移除最后一个元素),赋值给pathSteps
同样,slice 返回新数组,确保 @State 能检测到引用变化。
5.4 重置(resetAll)
resetAll() {
this.currentNode = familyTree; // 回到根节点
this.nodeStack = []; // 清空历史栈
this.pathSteps = []; // 清空路径
}
重置操作将应用恢复到初始状态,相当于用户重新开始查询。
六、辈分标签分类逻辑
6.1 分类规则
getRelationshipTag() 方法根据当前节点的名称,自动判断其辈分类别。这是应用中文化逻辑最密集的部分:
getRelationshipTag(): string {
if (this.pathSteps.length === 0) return "(自己)";
const name = this.currentNode.name;
const peerPatterns = ['哥哥', '弟弟', '姐姐', '妹妹', '兄弟', '姐妹', '堂', '表'];
const parentPatterns = ['爸爸', '妈妈', '父亲', '母亲', '爸', '妈',
'叔', '伯', '舅', '姑', '姨', '婶', '伯母', '舅妈', '姑父', '姨父'];
const grandparentPatterns = ['爷爷', '奶奶', '外公', '外婆', '祖父', '祖母', '曾'];
const spousePatterns = ['老公', '老婆', '丈夫', '妻子'];
const childPatterns = ['儿子', '女儿', '侄子', '侄女', '外甥', '外甥女',
'孙子', '孙女', '外孙', '外孙女', '侄孙', '儿媳', '女婿'];
const siblingInLawExact = ['大伯子', '二伯子', '小叔子', '大姑子', '小姑子',
'大舅子', '小舅子', '大姨子', '小姨子'];
if (childPatterns.some(p => name.includes(p))) return '(晚辈)';
if (grandparentPatterns.some(p => name.includes(p))) return '(长辈)';
if (siblingInLawExact.some(p => name.includes(p))) return '(平辈·姻亲)';
if (parentPatterns.some(p => name.includes(p))) return '(长辈)';
if (spousePatterns.some(p => name.includes(p))) return '(平辈·姻亲)';
if (peerPatterns.some(p => name.includes(p))) return '(平辈)';
return '';
}
6.2 匹配策略与优先级
匹配使用子串包含(name.includes(p))而非全等匹配,这是因为同一个汉字可能出现在多个称呼中。例如:
- “爷爷"包含"爷” → 匹配 grandparentPatterns
- “姑妈"包含"姑” → 匹配 parentPatterns
- “外甥"包含"外” → … "外"不在任何 patterns 中,但 “外甥” 本身在 childPatterns 中
检查顺序至关重要:
- childPatterns 优先:因为晚辈的称呼(如"侄子")不包含长辈特征字符,需要明确匹配
- grandparentPatterns:曾/祖字辈优先于父辈
- siblingInLawExact(特殊姻亲):如"大伯子"包含"伯"字,但实际是平辈
- parentPatterns:父辈亲属
- spousePatterns:配偶(平辈)
- peerPatterns:同辈亲属
6.3 边界情况处理
为什么需要 siblingInLawExact?
这是一个在开发过程中发现的典型边界问题。parentPatterns 中包含 '伯'、'叔'、'舅'、'姑'、'姨' 等短字符。对于"伯父"(长辈)来说,匹配 '伯' 是正确的;但对于"大伯子"(丈夫的哥哥),它同样包含 '伯' 字符,但辈分却是平辈·姻亲。
解决方案是在 parentPatterns 检查之前,先进行精确的名称全等匹配:
const siblingInLawExact = ['大伯子', '二伯子', '小叔子', '大姑子', '小姑子',
'大舅子', '小舅子', '大姨子', '小姨子'];
if (siblingInLawExact.some(p => name.includes(p))) return '(平辈·姻亲)';
由于这些名称具有唯一性且不与其他称呼重叠,使用子串包含(而非全等)即可安全匹配。
"儿媳"和"女婿"的分类归属:
“儿媳"包含"媳”(配偶含义),“女婿"包含"婿”(配偶含义)。如果先检查配偶模式,它们会被归类为"平辈·姻亲"。但儿媳/女婿是从子女配偶角度看的,在家族关系中属于晚辈。因此我们将它们放入 childPatterns 优先检查。
七、家族关系树构建详解
7.1 树构建策略
构建函数 buildFamilyTree() 采用自底向上(Bottom-Up) 策略,这是处理树形结构最直观的方式:
构建顺序:
1. 曾祖辈节点(叶子) → 曾祖父、曾祖母、外曾祖父、外曾祖母
2. 祖辈兄弟节点(叶子) → 伯祖父、姑祖母、舅祖父、姨祖母
3. 祖辈节点 → 爷爷、奶奶、外公、外婆
4. 父辈配偶节点(叶子) → 伯母、婶婶、姑父、舅妈、姨父
5. 同辈子代节点(叶子) → 堂兄弟、表兄弟、侄子、外甥等
6. 平辈节点 → 哥哥、弟弟、姐姐、妹妹
7. 姻亲节点 → 公公、婆婆、岳父、岳母、大伯子等
8. 子代节点 → 儿子、女儿
9. 父辈节点 → 爸爸、妈妈
10. 配偶节点 → 老公、老婆
11. 根节点 → 我
这种顺序保证了每个节点在作为子节点被引用时,它本身已经完全构建完成。
7.2 核心构建代码分析
以"爸爸"节点的构建为例,展示树节点的组装方式:
const baba: RelationTreeNode = {
name: '爸爸',
relations: [
{ relation: '父亲', node: yeye }, // 爸爸的父亲 → 爷爷
{ relation: '母亲', node: nainai }, // 爸爸的母亲 → 奶奶
{ relation: '哥哥', node: bofu }, // 爸爸的哥哥 → 伯父
{ relation: '弟弟', node: shushu }, // 爸爸的弟弟 → 叔叔
{ relation: '姐姐', node: guma }, // 爸爸的姐姐 → 姑妈
{ relation: '妹妹', node: gugu }, // 爸爸的妹妹 → 姑姑
{ relation: '儿子', node: gege }, // 爸爸的儿子 → 哥哥
{ relation: '女儿', node: jiejie }, // 爸爸的女儿 → 姐姐
]
};
这里 relation 字段的值(如"父亲"、“哥哥”)是用户看到的按钮文字,而 node 指向的 RelationTreeNode.name 是计算得出的称呼。
7.3 父系 vs 母系的区分
中国家族关系最核心的区分是父系(同姓)和母系(异姓):
| 关系 | 父系称呼 | 母系称呼 |
|---|---|---|
| 祖父 | 爷爷/祖父 | 外公/外祖父 |
| 祖母 | 奶奶/祖母 | 外婆/外祖母 |
| 父亲的兄弟 | 伯父、叔叔 | 舅舅 |
| 父亲的姐妹 | 姑妈、姑姑 | 姨妈、阿姨 |
| 父亲的兄弟之子 | 堂兄弟 | 表兄弟 |
| 父亲的姐妹之子 | 表兄弟 | 表兄弟 |
在代码中,父系和母系通过不同的节点路径来区分:
父系路径:我 → 父亲 → 哥哥 → 儿子 = 堂兄弟
母系路径:我 → 母亲 → 哥哥 → 儿子 = 表兄弟
父系(爸爸的哥哥的儿子)和母系(妈妈的哥哥的儿子)走到了不同的树分支,自然产出不同的称呼。
7.4 亲属命名的对称性
中国家族称谓展现了优美的对称性。以配偶父母的称呼为例:
男性角度(我→妻子→父亲 = 岳父): 女性角度(我→丈夫→父亲 = 公公):
我 → 妻子 → 父亲 = 岳父 我 → 丈夫 → 父亲 = 公公
我 → 妻子 → 母亲 = 岳母 我 → 丈夫 → 母亲 = 婆婆
我 → 妻子 → 哥哥 = 大舅子 我 → 丈夫 → 哥哥 = 大伯子
我 → 妻子 → 弟弟 = 小舅子 我 → 丈夫 → 弟弟 = 小叔子
我 → 妻子 → 姐姐 = 大姨子 我 → 丈夫 → 姐姐 = 大姑子
我 → 妻子 → 妹妹 = 小姨子 我 → 丈夫 → 妹妹 = 小姑子
代码中,这种对称性通过两个独立的节点分支实现:
const laogong: RelationTreeNode = { // 老公(自我为女性视角)
name: '老公',
relations: [
{ relation: '父亲', node: gonggong }, // 公公
{ relation: '母亲', node: popo }, // 婆婆
{ relation: '哥哥', node: dabozi }, // 大伯子
{ relation: '弟弟', node: xiaoshuzi }, // 小叔子
{ relation: '姐姐', node: daguzi }, // 大姑子
{ relation: '妹妹', node: xiaoguzi }, // 小姑子
]
};
const laopo: RelationTreeNode = { // 老婆(自我为男性视角)
name: '老婆',
relations: [
{ relation: '父亲', node: yuefu }, // 岳父
{ relation: '母亲', node: yueMu }, // 岳母
{ relation: '哥哥', node: dajiuzi }, // 大舅子
{ relation: '弟弟', node: xiaojiuzi }, // 小舅子
{ relation: '姐姐', node: dayizi }, // 大姨子
{ relation: '妹妹', node: xiaoyizi }, // 小姨子
]
};
八、 ArkTS 关键技术点总结
在开发亲戚称呼计算器的过程中,我们深度使用了以下 ArkTS 特性:
8.1 @Builder 装饰器
@Builder 用于定义可复用的 UI 片段。与常规方法不同,@Builder 方法内只能编写 UI 描述:
@Builder
buildRelationButton(entry: RelationEntry) {
Button(entry.relation)
.height(56)
.borderRadius(28)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.backgroundColor('#2C2C2E')
.fontColor(Color.White)
.margin({ left: 5, right: 5, bottom: 10 })
.onClick(() => {
this.navigateTo(entry);
})
}
注意事项:
@Builder函数体内不能包含let、for等命令式语句- 不能直接调用非 UI 方法(可以通过闭包回调间接调用)
- 通过
this.buildXxx()引用同一 struct 中的其他@Builder
8.2 ForEach 指令
ForEach 用于遍历数组并生成 UI 元素:
ForEach(this.currentNode.relations, (entry: RelationEntry) => {
this.buildRelationButton(entry)
}, (entry: RelationEntry) => entry.relation)
第三个参数是键值生成器,为每个元素生成唯一标识。当数据发生变化时,框架通过键值判断哪些元素需要新增、更新或删除。键值应该是稳定且唯一的。
8.3 Flex 弹性布局
Flex 的 FlexWrap.Wrap 模式支持按钮自动换行:
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
ForEach(this.currentNode.relations, (entry: RelationEntry) => {
this.buildRelationButton(entry)
}, (entry: RelationEntry) => entry.relation)
}
.width('100%')
.padding({ left: 12, right: 12 })
相比于手动分组为 Row 网格,FlexWrap 方式无需预先计算行数,当按钮数量变化时自动调整布局。
8.4 @State 数组更新
对于 @State 装饰的数组,必须通过创建新数组的方式来触发更新:
// ❌ 错误方式:不会触发 UI 更新
this.pathSteps.push('父亲');
// ✅ 正确方式:通过扩展运算符创建新数组
this.pathSteps = [...this.pathSteps, '父亲'];
// 或者
this.pathSteps = this.pathSteps.slice(0, -1); // 删除最后一项
这是因为 ArkTS 的状态检测基于引用对比——只有数组的引用发生变化时,框架才知道需要重新渲染。
8.5 Scroll 组件的使用
面包屑区域使用 Scroll 包裹,应对长路径的滚动需求:
Scroll() {
Row() {
// 面包屑内容
}
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
}
.width('100%')
.scrollable(ScrollDirection.Horizontal)
关键配置 .scrollable(ScrollDirection.Horizontal) 明确指定仅横向滚动,避免与页面垂直滚动冲突。
九、构建验证与调试
9.1 编译构建
在项目根目录执行以下命令进行编译验证:
hvigorw assembleHap --daemon=false --analyze=false
构建过程输出解读:
UP-TO-DATE :entry:default@ProcessRouterMap...
Finished :entry:default@CompileArkTS... after 1 s 150 ms
Finished :entry:default@PackageHap... after 574 ms
Finished :entry:default@PackingCheck... after 4 ms
BUILD SUCCESSFUL in 2 s 922 ms
各阶段作用:
| 阶段 | 说明 |
|---|---|
CompileArkTS |
编译所有 .ets 文件,进行语法检查和类型检查。耗时约 1-2 秒 |
PackageHap |
将编译产物和资源打包为 HAP 文件 |
PackingCheck |
校验包内容是否合规(文件大小限制、资源完整性等) |
9.2 常见编译错误及解决方案
错误类型 1:@Builder 中包含命令式代码
ERROR: Only UI component syntax can be written here.
At File: index3.ets:145:9
原因:在 @Builder 方法中使用了 let、for 等命令式语句。
解决方案:将命令式逻辑移出 @Builder,或在 build() 方法中预处理数据后传入。具体到本项目,我们使用 FlexWrap.Wrap 替代了手动分组循环。
错误类型 2:引用了不存在的资源
ERROR: Unknown resource name 'ic_chevron_right'.
原因:使用了 $r('app.media.ic_chevron_right') 的图片资源,但该文件不存在。
解决方案:使用 Unicode 字符 › 替代图片资源,避免了对资源文件的依赖。
错误类型 3:类型不匹配
ERROR: Type 'string[]' is not assignable to type 'string'.
原因:方法返回值类型声明与实际返回类型不一致。ArkTS 的类型检查比 TypeScript 更严格。
解决方案:确保所有函数的参数类型和返回值类型与实际使用一致。
9.3 调试技巧
在开发过程中,可以通过日志输出来追踪应用状态:
// 在 navigateTo 方法中加入日志
navigateTo(entry: RelationEntry) {
console.info(`[FamilyTree] Navigating to: ${entry.relation} -> ${entry.node.name}`);
console.info(`[FamilyTree] Path: ${this.pathSteps.join(' › ')}`);
this.nodeStack.push(this.currentNode);
this.currentNode = entry.node;
this.pathSteps = [...this.pathSteps, entry.relation];
}
此外,DevEco Studio 的 Previewer 功能可以实时预览 UI 变化,无需连接真机或启动模拟器,对于调试布局问题非常高效。
十、应用的文化内涵与扩展方向
10.1 中国家族称谓的文化背景
中国家族称谓体系是宗法制度的产物,体现了中华文化中"内外有别"、"长幼有序"的核心价值观:
- 内外有别:父系亲属(爷爷、奶奶、伯父、叔叔、姑妈)和母系亲属(外公、外婆、舅舅、姨妈)使用不同的称谓体系
- 长幼有序:父亲的哥哥叫"伯父",父亲的弟弟叫"叔叔";母亲的哥哥和弟弟都叫"舅舅"——父系区分长幼,母系不区分
- 宗族延续:堂兄弟同姓(同宗),表兄弟异姓(外亲),在传统社会中影响继承权、祭祀权等重大事项
10.2 扩展方向
当前应用已经覆盖了超过 60 种常见亲属关系,但仍有很大的扩展空间:
直系祖先追溯:
可以增加更多祖先辈的查询,如高祖父、高祖母、天祖、烈祖等。这些称呼在族谱修缮、寻根问祖等场景中非常有用。
三级关系链计算:
当前应用支持两级关系链(如"父亲的哥哥的儿子")。可以扩展到三级甚至四级关系链,覆盖更复杂的场景:
我的父亲的哥哥的妻子的儿子 = 堂兄弟
我的母亲的姐姐的丈夫的父亲 = ? (目前未覆盖)
地区方言支持:
中国不同地区对同一亲属有不同的称呼。例如:
- 普通话"奶奶" → 河南话"奶奶" / 四川话"婆婆" / 粤语"阿嫲"
- 普通话"外公" → 部分北方地区"姥爷"
可以增加方言切换功能,基于同一关系树输出不同地区的称呼。
族谱可视化:
使用 Canvas 绘制家族树形图,直观展示从"我"出发到各层亲属的完整谱系关系。这对于教育孩子理解家族结构非常有帮助。
关系反向查询:
当前应用是从"我"出发正向查找。可以增加反向查询功能:输入一个称呼(如"表姐"),应用自动推导出这层关系对应的路径(“母亲的哥哥的女儿"或"母亲的姐姐的女儿”)。这在技术上是树的逆向路径搜索问题,比正向查找更具挑战性。
10.3 技术架构的可扩展性
当前基于树节点的架构具有良好的可扩展性:
- 新增节点:只需在
buildFamilyTree()中定义新节点,并在相关父节点的relations中添加引用即可 - 新增关系类型:只需在
RelationEntry.relation中使用新的字符串标识 - 新增语言/方言:可以创建多棵树(如
familyTreeMandarin、familyTreeCantonese),用户切换时替换根节点
十一、完整代码分析
11.1 代码结构概览
亲戚称呼计算器的完整代码(583 行)可以分为三个逻辑部分:
| 部分 | 行号范围 | 代码量 | 说明 |
|---|---|---|---|
| 组件定义与 UI | 1-258 | 258 行 | @Entry struct, @Builder, 导航方法 |
| 数据模型接口 | 261-269 | 9 行 | RelationEntry, RelationTreeNode |
| 家族树构建 | 272-586 | 315 行 | buildFamilyTree() 函数 |
11.2 核心设计模式:组合优于继承
整个应用没有使用任何类继承,而是通过接口组合和函数构建来实现复杂的数据结构:
interface RelationTreeNode {
name: string;
relations: RelationEntry[];
}
interface RelationEntry {
relation: string;
node: RelationTreeNode;
}
一个 RelationTreeNode 包含多个 RelationEntry,每个 RelationEntry 指向另一个 RelationTreeNode。这种组合模式比继承体系更灵活,可以轻松应对家族关系中各种非规则结构(如"爸爸"有 8 种关系,"堂兄弟"有 0 种关系)。
11.3 状态流转图
应用的核心状态流转可以用状态图表示:
┌──────────┐
│ 根节点 │
│ (我) │
└────┬─────┘
│ 点击关系按钮
▼
┌──────────────────────┐
│ navigateTo() │
│ 1. 当前节点压栈 │
│ 2. 更新 current │
│ 3. 追加 pathSteps │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ 目标节点 │
│ (如 伯父) │
│ relations>0? │
└───────┬──────────────┘
│
┌───────────┴───────────┐
│ 是 │ 否
▼ ▼
┌──────────────┐ ┌──────────────┐
│ 显示关系按钮 │ │ 显示"最终称呼" │
│ 等待用户选择 │ │ (叶子节点) │
└──────┬───────┘ └──────┬───────┘
│ │
点击"上一步" 点击"上一步"
│ │
▼ ▼
┌───────────────────────────────────┐
│ goBack() │
│ 1. nodeStack.pop() → 恢复当前节点 │
│ 2. pathSteps.slice(-1) → 删最后一步 │
└───────────────────────────────────┘
十二、总结
本文通过构建一个亲戚称呼计算器应用,系统性地展示了 HarmonyOS NEXT 上 ArkTS 语言开发完整应用的方方面面。从数据模型设计、家族关系树构建、UI 界面实现、状态管理到辈分分类逻辑,每一个环节都有其独特的技术挑战和文化内涵。
12.1 所学的核心技术
- ArkTS 接口与递归数据结构:通过
RelationTreeNode和RelationEntry构建多叉树 - @Builder 声明式 UI:将面包屑、名称显示、按钮区域分解为可复用的 UI 片段
- @State 响应式编程:管理路径状态,通过数组引用变更触发 UI 更新
- FlexWrap 弹性布局:自动换行排列按钮,无需硬编码网格
- 树形导航算法:前进(push)、后退(pop)、重置的全栈实现
- 字符串模式匹配:基于子串匹配的辈分分类逻辑
12.2 文化软件的价值
亲戚称呼计算器不仅仅是一个技术演示项目,它也具有实实在在的文化价值。在快节奏的现代社会中,年轻一代对传统家族称谓的熟悉程度在下降。一款好用的工具可以帮助人们:
- 在春节聚会时准确称呼远亲
- 填写族谱或家族关系表格
- 教育下一代了解家族结构和称谓文化
- 在跨地区、跨文化的社交中避免称谓尴尬
12.3 写在最后
HarmonyOS NEXT 作为新一代国产操作系统,为应用开发者提供了完整的工具链和完善的开发体验。ArkTS 作为其原生 UI 语言,在类型安全、组件化、响应式编程等方面表现优异,尤其适合实现具有复杂数据结构和交互逻辑的应用。
亲戚称呼计算器虽然是一个看似简单的工具,但它涵盖了声明式 UI 开发的核心概念——组件化、状态管理、事件处理、布局系统、数据驱动——这些知识可以无缝迁移到更复杂的应用场景中。希望本文能为你在 HarmonyOS NEXT 应用开发之路上,尤其是在文化类应用开发方面,提供有价值的参考和启发。
更多推荐


所有评论(0)