欢迎加入开源鸿蒙 PC社区

https://harmonypc.csdn.net/

效果截图

在这里插入图片描述


第9篇:词根分解与水印展示

系列教程导航

篇号 标题 状态
01 环境搭建与项目创建 ✅ 已完成
02 数据模型与单词仓库 ✅ 已完成
03 主入口页面与导航结构 ✅ 已完成
04 极速划词页面实现 ✅ 已完成
05 手写画布实现 ✅ 已完成
06 百度OCR手写识别接入 ✅ 已完成
07 答案比对与反馈UI ✅ 已完成
08 单词切换与底部导航 ✅ 已完成
09 词根分解与水印展示 📖 本篇
10 项目总结与优化方向 ⏳ 下一篇

源码仓库https://gitcode.com/qq_33247427/englishProject


一、WordPart 数据结构回顾

在第 2 篇中,我们定义了词根词缀的数据结构。词根词缀分解是英语学习中非常有效的记忆方法,通过将一个复杂的单词拆解为有意义的组成部分,帮助学习者理解单词的构造逻辑,从而实现"见词知义"的效果。在我们的应用中,每个单词都可以选择性地附带词根分解信息,让用户在默写的同时加深对单词构词法的理解。这里回顾一下数据结构的定义:

/**
 * 词根/词缀分解项
 */
export interface WordPart {
  /** 词根/词缀文本,如 "electr" */
  text: string;
  /** 含义,如 "电" */
  meaning: string;
  /** 类型:prefix(前缀)| root(词根)| suffix(后缀) */
  type: string;
}

/**
 * 单词详情
 */
export interface WordDetail {
  /** 词根词缀分解列表 */
  parts: WordPart[];
}
1.1 实际数据示例

international 为例:

{
  english: 'international',
  detail: {
    parts: [
      { text: 'inter', meaning: '在…之间', type: 'prefix' },
      { text: 'nation', meaning: '国家', type: 'root' },
      { text: 'al', meaning: '形容词后缀', type: 'suffix' }
    ]
  }
}

分解后的展示效果:[inter 在…之间] + [nation 国家] + [al 形容词后缀]

每个部分用不同颜色的 Chip(标签)展示,让用户一眼区分前缀、词根、后缀。


二、颜色编码设计

2.1 三种类型的颜色方案
type 中文名 背景色 文字色 设计意图
prefix 前缀 #FFEBEE(浅红) #E57373(红色) 红色表示"开头",引起注意
root 词根 #EEF1E4(浅绿) #8B9D6B(绿色) 绿色表示"核心",与主题色一致
suffix 后缀 #DBEAFE(浅蓝) #64B5F6(蓝色) 蓝色表示"结尾",冷色收束
2.2 颜色选择原则
  1. 背景色极浅:不干扰文字阅读,只起到分组区分作用
  2. 文字色中等饱和度:既能看清,又不刺眼
  3. 三色互不混淆:红/绿/蓝三原色方向,色弱用户也能区分
  4. 与主题色协调:词根用主题绿 #8B9D6B,保持整体一致性
2.3 实际效果示例
┌─────────────────────────────────────────────────────┐
│  [inter]  +  [nation]  +  [al]                      │
│  在…之间      国家         形容词后缀                  │
│  (浅红底)    (浅绿底)     (浅蓝底)                    │
└─────────────────────────────────────────────────────┘

三、WordPartsRow @Builder 组件

3.1 @Builder 装饰器

ArkUI 的 @Builder 装饰器用于定义可复用的 UI 片段。它比 @Component 更轻量,适合不需要独立状态管理的 UI 块。在我们的项目中,词根分解行需要在多个地方使用(水印层、提示区、极速划词页面),将其封装为 @Builder 方法可以避免代码重复。@Builder 方法定义在组件内部,可以直接访问组件的 this 上下文,包括状态变量和其他方法,使用起来非常方便。

// @Builder 定义在组件内部,可以访问组件的 this
@Builder
WordPartsRow(parts: WordPart[]) {
  // UI 描述
}

// 调用方式
this.WordPartsRow(this.currentWord.detail.parts)
3.2 完整实现
@Builder
WordPartsRow(parts: WordPart[]) {
  Row({ space: 4 }) {
    ForEach(parts, (part: WordPart, index: number) => {
      // 每个词根/词缀的 Chip
      Row({ space: 4 }) {
        Column({ space: 2 }) {
          // 词根文本
          Text(part.text)
            .fontSize(15)
            .fontWeight(FontWeight.Medium)
            .fontColor(this.textColorForPart(part.type))

          // 含义
          Text(part.meaning)
            .fontSize(11)
            .fontColor('#6B7280')
        }
        .padding({ left: 14, right: 14, top: 8, bottom: 8 })
        .backgroundColor(this.backgroundColorForPart(part.type))
        .borderRadius(8)

        // 词根之间的 "+" 分隔符
        if (index < parts.length - 1) {
          Text('+')
            .fontSize(16)
            .fontColor('#9CA3AF')
            .margin({ left: 4, right: 4 })
        }
      }
      .alignItems(VerticalAlign.Center)
    }, (part: WordPart, index: number) => `${part.text}-${index}`)
  }
  .justifyContent(FlexAlign.Center)
  .width('100%')
}
3.3 颜色辅助方法
/**
 * 根据词根类型返回背景色
 */
private backgroundColorForPart(type: string): string {
  if (type === 'prefix') {
    return '#FFEBEE';  // 浅红
  }
  if (type === 'root') {
    return '#EEF1E4';  // 浅绿
  }
  if (type === 'suffix') {
    return '#DBEAFE';  // 浅蓝
  }
  return '#F5F5F5';    // 默认浅灰
}

/**
 * 根据词根类型返回文字色
 */
private textColorForPart(type: string): string {
  if (type === 'prefix') {
    return '#E57373';  // 红色
  }
  if (type === 'root') {
    return '#8B9D6B';  // 绿色(主题色)
  }
  if (type === 'suffix') {
    return '#64B5F6';  // 蓝色
  }
  return '#333333';    // 默认深色
}
3.4 ForEach 的 key 函数
ForEach(parts, (part: WordPart, index: number) => {
  // 渲染逻辑
}, (part: WordPart, index: number) => `${part.text}-${index}`)

key 函数使用 text + index 组合,确保即使有重复的词根文本(如 un- 出现两次),也能正确区分。


四、在水印中展示词根分解

4.1 水印层的位置

水印层位于 Stack 的第二层,在白色底层之上、Canvas 手写层之下:

Stack 层级(从下到上):
1. 白色底层 → 保证 OCR 截图有白色背景
2. 水印层   → 显示参考文字和词根分解 ← 本节内容
3. Canvas   → 接收触摸事件,显示笔迹
4. 反馈浮层 → 显示识别结果
5. 工具栏   → 操作按钮
4.2 水印层完整实现
// 水印层(条件渲染:开关开启且有当前单词时显示)
if (this.showWatermark && this.currentWord !== null) {
  Column({ space: 12 }) {
    // 单词(大字、极浅色,作为书写参考)
    Text(this.currentWord.english)
      .fontSize(48)
      .fontColor('#E8EDE0')
      .fontWeight(FontWeight.Bold)
      .fontStyle(FontStyle.Italic)

    // 音标(中等大小、浅色)
    Text(this.currentWord.phonetic)
      .fontSize(18)
      .fontColor('#D0D8C8')

    // 词根分解(条件渲染)
    if (this.currentWord.detail !== undefined &&
        this.currentWord.detail.parts !== undefined &&
        this.currentWord.detail.parts.length > 0) {
      this.WordPartsRow(this.currentWord.detail.parts)
    }
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
  .hitTestBehavior(HitTestMode.Transparent)  // 不拦截触摸
}
4.3 水印层的设计考量

水印的核心原则是:可见但不干扰。用户需要能看到参考文字,但水印不能影响手写和 OCR 识别。


五、水印颜色选择

5.1 为什么水印颜色这么浅?
元素 颜色 不透明度感知 原因
单词 #E8EDE0 ~10% 可见度 最大的文字,必须最浅,否则干扰书写
音标 #D0D8C8 ~15% 可见度 辅助信息,比单词稍深一点
词根 Chip 各类型浅色 ~20% 可见度 有背景色区分,可以稍深
5.2 颜色对比
// 水印颜色 vs 正常显示颜色
// 水印中的单词
'#E8EDE0'  // 极浅绿灰,几乎看不见

// 正常显示的单词(如极速划词页面)
'#374151'  // 深灰色,清晰可读

// 手写笔迹颜色
'#1A1A1A'  // 近黑色,最高对比度
5.3 为什么不用 opacity?
// ❌ 不推荐:用 opacity 控制透明度
Text(this.currentWord.english)
  .fontSize(48)
  .fontColor('#374151')
  .opacity(0.1)

// ✅ 推荐:直接用浅色值
Text(this.currentWord.english)
  .fontSize(48)
  .fontColor('#E8EDE0')

原因:

  1. opacity 会影响整个元素及其子元素,不够精确
  2. 直接用颜色值更直观,设计师给的色值可以直接使用
  3. 性能上,固定颜色比动态透明度计算更高效

六、ArkTS 条件渲染注意事项

ArkTS 的条件渲染机制与传统前端框架有一些重要的区别。由于 ArkTS 是基于 TypeScript 的静态类型语言,并且在编译阶段会进行严格的类型检查和语法分析,因此在 UI 描述代码中有一些特殊的限制。理解这些限制对于避免编译错误和运行时异常非常重要。

6.1 不支持可选链

ArkTS 在 UI 描述(build() 方法内)中不支持可选链操作符 ?.

// ❌ 编译错误:UI 描述中不支持可选链
if (this.currentWord?.detail?.parts?.length > 0) {
  this.WordPartsRow(this.currentWord.detail.parts)
}

// ✅ 正确:逐层判断
if (this.currentWord !== null &&
    this.currentWord.detail !== undefined &&
    this.currentWord.detail.parts !== undefined &&
    this.currentWord.detail.parts.length > 0) {
  this.WordPartsRow(this.currentWord.detail.parts)
}
6.2 undefined vs null

在 ArkTS 中,可选字段(detail?: WordDetail)的默认值是 undefined,不是 null

// 可选字段未赋值时
const word: VocabularyWord = { id: '1', english: 'hello', meaning: '你好', phonetic: '/həˈloʊ/' };
console.log(word.detail);  // undefined(不是 null)

// 因此判断时用 !== undefined
if (this.currentWord.detail !== undefined) {
  // detail 存在
}

// 或者用真值判断(undefined 和 null 都是 falsy)
if (this.currentWord.detail) {
  // detail 存在且不为 null/undefined
}
6.3 条件渲染的层级

ArkTS 的条件渲染必须在组件的直接子级使用,不能嵌套在属性链中:

// ❌ 错误:不能在属性链中使用 if
Column() {
  Text('hello')
    .fontSize(16)
    if (condition) {  // 语法错误
      .fontColor('red')
    }
}

// ✅ 正确:条件渲染整个组件
Column() {
  if (condition) {
    Text('hello')
      .fontSize(16)
      .fontColor('red')
  } else {
    Text('hello')
      .fontSize(16)
      .fontColor('gray')
  }
}

// ✅ 也正确:用三元表达式处理属性值
Column() {
  Text('hello')
    .fontSize(16)
    .fontColor(condition ? 'red' : 'gray')
}

七、水印开关 Toggle

7.1 状态定义

水印开关是一个典型的布尔状态控制场景。在默写学习中,水印的作用是提供书写参考,帮助用户回忆单词的拼写。对于初学者来说,水印是非常有用的辅助工具;而对于已经熟练掌握的用户,关闭水印可以增加默写的挑战性,更好地检验记忆效果。因此我们提供了一个开关让用户自主选择。

/** 是否显示水印(默认开启) */
@State showWatermark: boolean = true;

默认开启水印,因为大多数用户在默写时需要参考。高级用户可以关闭水印进行"盲写"挑战。

7.2 Toggle 组件用法
Row({ space: 6 }) {
  Text('水印')
    .fontSize(12)
    .fontColor('#6B7280')

  Toggle({ type: ToggleType.Switch, isOn: this.showWatermark })
    .selectedColor('#8B9D6B')   // 开启时滑块颜色(主题绿)
    .switchPointColor('#FFFFFF') // 滑块圆点颜色(白色)
    .width(40)
    .height(22)
    .onChange((isOn: boolean) => {
      this.showWatermark = isOn;
    })
}
.alignItems(VerticalAlign.Center)
7.3 Toggle 的类型

ArkUI 提供三种 Toggle 类型:

类型 外观 适用场景
ToggleType.Switch iOS 风格滑动开关 开/关设置
ToggleType.Checkbox 方形勾选框 多选列表
ToggleType.Button 按钮形态切换 工具栏选项

我们使用 Switch 类型,因为水印是一个"开/关"的二元状态,滑动开关最直观。

7.4 水印开关与画布的联动
// 水印层的条件渲染依赖 showWatermark 状态
if (this.showWatermark && this.currentWord !== null) {
  Column({ space: 12 }) {
    // 水印内容...
  }
  .hitTestBehavior(HitTestMode.Transparent)
}

showWatermarktrue 变为 false 时,ArkUI 会自动移除水印层的 DOM 节点。当从 false 变为 true 时,重新创建。这是声明式 UI 的优势——你只需描述"什么条件下显示什么",框架负责增删节点。


八、HandwritingCanvas 组件中的 Canvas 绘制水印

8.1 为什么需要 Canvas 绘制方案?

在独立的 HandwritingCanvas 组件中,水印不是通过 ArkUI 组件叠加实现的,而是直接在 Canvas 上绘制。这种方案的核心优势在于:水印和笔迹在同一个绘图上下文中,可以一起导出为图片。当用户需要保存自己的手写练习成果时,导出的图片会包含水印参考文字和用户的笔迹,形成一个完整的学习记录。此外,在组件封装的场景下,使用 Canvas 绘制可以避免复杂的 Stack 层级嵌套,让组件的接口更加简洁。这种方案适用于以下场景:

  1. 需要精确控制水印位置和大小
  2. 水印需要和笔迹在同一个 Canvas 上(用于截图)
  3. 组件封装时不方便使用 Stack 叠加
8.2 Canvas 绘制词根 Chip
/**
 * 在 Canvas 上绘制词根分解 Chip
 * @param ctx Canvas 2D 上下文
 * @param parts 词根分解数组
 * @param centerX 中心 X 坐标
 * @param startY 起始 Y 坐标
 */
private drawWordPartsOnCanvas(
  ctx: CanvasRenderingContext2D,
  parts: WordPart[],
  centerX: number,
  startY: number
): void {
  const chipHeight: number = 36;
  const chipPadding: number = 12;
  const chipGap: number = 8;
  const plusWidth: number = 20;

  // 计算总宽度(用于居中)
  let totalWidth: number = 0;
  for (let i = 0; i < parts.length; i++) {
    const part: WordPart = parts[i];
    ctx.font = '14px sans-serif';
    const textWidth: number = ctx.measureText(part.text).width;
    const meaningWidth: number = ctx.measureText(part.meaning).width;
    const chipWidth: number = Math.max(textWidth, meaningWidth) + chipPadding * 2;
    totalWidth += chipWidth;
    if (i < parts.length - 1) {
      totalWidth += plusWidth + chipGap * 2;
    }
  }

  // 从左侧开始绘制
  let currentX: number = centerX - totalWidth / 2;

  for (let i = 0; i < parts.length; i++) {
    const part: WordPart = parts[i];
    const bgColor: string = this.backgroundColorForPart(part.type);
    const textColor: string = this.textColorForPart(part.type);

    // 测量文字宽度
    ctx.font = 'bold 14px sans-serif';
    const textWidth: number = ctx.measureText(part.text).width;
    ctx.font = '11px sans-serif';
    const meaningWidth: number = ctx.measureText(part.meaning).width;
    const chipWidth: number = Math.max(textWidth, meaningWidth) + chipPadding * 2;

    // 绘制圆角矩形背景
    this.drawRoundRect(ctx, currentX, startY, chipWidth, chipHeight, 6);
    ctx.fillStyle = bgColor;
    ctx.fill();

    // 绘制词根文本
    ctx.font = 'bold 14px sans-serif';
    ctx.fillStyle = textColor;
    ctx.textAlign = 'center';
    ctx.fillText(part.text, currentX + chipWidth / 2, startY + 15);

    // 绘制含义
    ctx.font = '11px sans-serif';
    ctx.fillStyle = '#6B7280';
    ctx.fillText(part.meaning, currentX + chipWidth / 2, startY + 30);

    currentX += chipWidth;

    // 绘制 "+" 分隔符
    if (i < parts.length - 1) {
      currentX += chipGap;
      ctx.font = '16px sans-serif';
      ctx.fillStyle = '#9CA3AF';
      ctx.textAlign = 'center';
      ctx.fillText('+', currentX + plusWidth / 2, startY + chipHeight / 2 + 5);
      currentX += plusWidth + chipGap;
    }
  }
}

/**
 * 绘制圆角矩形路径
 */
private drawRoundRect(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number
): void {
  ctx.beginPath();
  ctx.moveTo(x + radius, y);
  ctx.lineTo(x + width - radius, y);
  ctx.arcTo(x + width, y, x + width, y + radius, radius);
  ctx.lineTo(x + width, y + height - radius);
  ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
  ctx.lineTo(x + radius, y + height);
  ctx.arcTo(x, y + height, x, y + height - radius, radius);
  ctx.lineTo(x, y + radius);
  ctx.arcTo(x, y, x + radius, y, radius);
  ctx.closePath();
}
8.3 backgroundColorForPart / textColorForPart 方法

这两个方法在第三节已经定义过,Canvas 绘制方案中复用相同的颜色逻辑:

private backgroundColorForPart(type: string): string {
  if (type === 'prefix') return '#FFEBEE';
  if (type === 'root') return '#EEF1E4';
  if (type === 'suffix') return '#DBEAFE';
  return '#F5F5F5';
}

private textColorForPart(type: string): string {
  if (type === 'prefix') return '#E57373';
  if (type === 'root') return '#8B9D6B';
  if (type === 'suffix') return '#64B5F6';
  return '#333333';
}
8.4 两种水印方案对比
对比项 ArkUI 组件叠加 Canvas 2D 绘制
实现复杂度 低(声明式) 高(命令式)
布局灵活性 高(自动布局) 中(手动计算)
性能 好(框架优化) 好(直接绘制)
截图包含水印 否(不同层) 是(同一 Canvas)
适用场景 页面内嵌画布 独立画布组件

本项目中,默写页面使用 ArkUI 组件叠加方案(Stack 分层),因为水印不需要出现在 OCR 截图中。独立的 HandwritingCanvas 组件使用 Canvas 绘制方案,适合需要导出完整图片的场景。


九、本篇小结

通过本篇教程,我们完成了词根分解与水印展示的完整实现:

  • ✅ 回顾 WordPart 数据结构(text + meaning + type)
  • ✅ 设计颜色编码系统(prefix 红 / root 绿 / suffix 蓝)
  • ✅ 实现 WordPartsRow @Builder 组件(ForEach + Chip + “+” 分隔符)
  • ✅ 水印层展示词根分解(Stack 第二层,条件渲染)
  • ✅ 水印颜色选择原则(极浅色,不干扰书写和识别)
  • ✅ ArkTS 条件渲染注意事项(不支持可选链,逐层判断)
  • ✅ Toggle 水印开关(Switch 类型,主题色)
  • ✅ Canvas 2D 绘制词根 Chip(独立组件方案)

本篇新增/修改的文件

electron/src/main/ets/
├── pages/
│   └── SpeedVocabPage.ets       ← 修改:添加水印层和词根展示
└── components/
    └── HandwritingCanvas.ets    ← 修改:添加 Canvas 绘制水印方法

十、开发过程中的常见问题

10.1 词根 Chip 布局溢出

现象:当单词有很多词根部分时(如 4-5 个),Chip 超出屏幕宽度。

解决方案:使用 Flex 布局替代 Row,允许自动换行:

Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
  ForEach(parts, (part: WordPart, index: number) => {
    // Chip 内容...
  })
}

或者当词根数量超过 3 个时,缩小字体:

const fontSize = parts.length > 3 ? 12 : 15;
10.2 水印层影响 OCR 识别

现象:开启水印后,OCR 把水印文字也识别出来了。

原因componentSnapshot 截取的是整个 Stack 的视觉内容,包括水印层。

解决:截图前临时隐藏水印,截图后恢复:

async doRecognize(): Promise<void> {
  // 临时隐藏水印
  const wasShowingWatermark = this.showWatermark;
  this.showWatermark = false;

  // 等待一帧让 UI 更新
  await new Promise<void>((resolve) => setTimeout(resolve, 50));

  // 截图并识别
  const pixelMap = await componentSnapshot.get('handwriting-canvas');
  const result = await this.ocrService.recognizeHandwriting(pixelMap);

  // 恢复水印
  this.showWatermark = wasShowingWatermark;

  this.checkAnswer(result);
}
10.3 条件渲染导致动画丢失

现象:水印开关切换时,水印突然出现/消失,没有过渡动画。

原因if/else 条件渲染是直接创建/销毁节点,没有过渡效果。

解决:使用 visibilityopacity 配合动画,而不是条件渲染:

Column({ space: 12 }) {
  // 水印内容...
}
.opacity(this.showWatermark ? 1 : 0)
.animation({ duration: 200, curve: Curve.EaseInOut })
10.4 ForEach 的 key 冲突

现象:词根列表渲染异常,显示重复或错位。

原因:key 函数返回了重复的值。如果两个词根的 text 相同(如 un- 出现两次),会导致 key 冲突。

解决:在 key 中加入 index:

ForEach(parts, (part: WordPart, index: number) => {
  // ...
}, (part: WordPart, index: number) => `${part.text}-${index}`)

下一篇预告

第 10 篇:项目总结与优化方向 — 最后一篇,我们将回顾整体架构、总结关键技术决策,并探讨后续的功能增强、性能优化和发布准备。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐