在这里插入图片描述

在这里插入图片描述

开发环境: 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.jsonsrc 数组的第一个页面。如果想要将亲戚称呼计算器设为默认启动页,只需将数组顺序调整为 ["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 又是一个 RelationTreeNode
  • name 字段存储的是从当前人物角度对该节点的称呼。例如,从"我"的角度,“父亲的哥哥"对应的节点 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;
}

这种构建方式有几个优点:

  1. 引用共享:多个节点可以引用同一个子节点。例如"爸爸"的"父亲"和"伯父"的"父亲"都指向同一个"爷爷"节点,确保了数据一致性
  2. 避免循环引用:家族关系是单向的(从长辈到晚辈),不会出现循环,适合于树结构
  3. 构建顺序清晰:从叶子到根,不会出现引用未定义节点的问题

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)加粗,表示当前位置
  • 其他项使用灰色,ForEachindex 参数用于区分不同位置

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.namethis.currentNode.relations 的地方并不会自动刷新。那为什么能正常工作?

答案在于:虽然 currentNode 本身不是响应式的,但 pathSteps 是响应式的。每次 navigateTogoBack 都会修改 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];  // 追加路径
}

前进操作三部曲:

  1. 将当前节点压入 nodeStack 历史栈,为"上一步"做准备
  2. currentNode 更新为用户选择的目标节点
  3. 将选择的关系描述追加到 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);  // 删除最后一步
}

后退操作与前进对称:

  1. nodeStack 弹出上一个节点,恢复为当前节点
  2. 使用 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 中

检查顺序至关重要:

  1. childPatterns 优先:因为晚辈的称呼(如"侄子")不包含长辈特征字符,需要明确匹配
  2. grandparentPatterns:曾/祖字辈优先于父辈
  3. siblingInLawExact(特殊姻亲):如"大伯子"包含"伯"字,但实际是平辈
  4. parentPatterns:父辈亲属
  5. spousePatterns:配偶(平辈)
  6. 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 函数体内不能包含 letfor 等命令式语句
  • 不能直接调用非 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 弹性布局

FlexFlexWrap.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 方法中使用了 letfor 等命令式语句。

解决方案:将命令式逻辑移出 @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 中使用新的字符串标识
  • 新增语言/方言:可以创建多棵树(如 familyTreeMandarinfamilyTreeCantonese),用户切换时替换根节点

十一、完整代码分析

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 接口与递归数据结构:通过 RelationTreeNodeRelationEntry 构建多叉树
  • @Builder 声明式 UI:将面包屑、名称显示、按钮区域分解为可复用的 UI 片段
  • @State 响应式编程:管理路径状态,通过数组引用变更触发 UI 更新
  • FlexWrap 弹性布局:自动换行排列按钮,无需硬编码网格
  • 树形导航算法:前进(push)、后退(pop)、重置的全栈实现
  • 字符串模式匹配:基于子串匹配的辈分分类逻辑

12.2 文化软件的价值

亲戚称呼计算器不仅仅是一个技术演示项目,它也具有实实在在的文化价值。在快节奏的现代社会中,年轻一代对传统家族称谓的熟悉程度在下降。一款好用的工具可以帮助人们:

  • 在春节聚会时准确称呼远亲
  • 填写族谱或家族关系表格
  • 教育下一代了解家族结构和称谓文化
  • 在跨地区、跨文化的社交中避免称谓尴尬

12.3 写在最后

HarmonyOS NEXT 作为新一代国产操作系统,为应用开发者提供了完整的工具链和完善的开发体验。ArkTS 作为其原生 UI 语言,在类型安全、组件化、响应式编程等方面表现优异,尤其适合实现具有复杂数据结构和交互逻辑的应用。

亲戚称呼计算器虽然是一个看似简单的工具,但它涵盖了声明式 UI 开发的核心概念——组件化、状态管理、事件处理、布局系统、数据驱动——这些知识可以无缝迁移到更复杂的应用场景中。希望本文能为你在 HarmonyOS NEXT 应用开发之路上,尤其是在文化类应用开发方面,提供有价值的参考和启发。

Logo

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

更多推荐