09词根分解与水印展示-鸿蒙PC端Electron开发
本文介绍了开源鸿蒙PC社区中英语学习应用的词根分解与水印展示功能。文章首先回顾了WordPart数据结构,展示了如何将单词拆解为词根词缀并标注含义。接着详细设计了三种词根类型的颜色编码方案,通过不同背景色和文字色区分前缀、词根和后缀。然后介绍了使用ArkUI的@Builder装饰器实现可复用的词根分解行组件,包括颜色辅助方法和ForEach循环处理。最后讲解了如何将词根分解信息集成到应用的水印层中
欢迎加入开源鸿蒙 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 颜色选择原则
- 背景色极浅:不干扰文字阅读,只起到分组区分作用
- 文字色中等饱和度:既能看清,又不刺眼
- 三色互不混淆:红/绿/蓝三原色方向,色弱用户也能区分
- 与主题色协调:词根用主题绿
#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')
原因:
opacity会影响整个元素及其子元素,不够精确- 直接用颜色值更直观,设计师给的色值可以直接使用
- 性能上,固定颜色比动态透明度计算更高效
六、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)
}
当 showWatermark 从 true 变为 false 时,ArkUI 会自动移除水印层的 DOM 节点。当从 false 变为 true 时,重新创建。这是声明式 UI 的优势——你只需描述"什么条件下显示什么",框架负责增删节点。
八、HandwritingCanvas 组件中的 Canvas 绘制水印
8.1 为什么需要 Canvas 绘制方案?
在独立的 HandwritingCanvas 组件中,水印不是通过 ArkUI 组件叠加实现的,而是直接在 Canvas 上绘制。这种方案的核心优势在于:水印和笔迹在同一个绘图上下文中,可以一起导出为图片。当用户需要保存自己的手写练习成果时,导出的图片会包含水印参考文字和用户的笔迹,形成一个完整的学习记录。此外,在组件封装的场景下,使用 Canvas 绘制可以避免复杂的 Stack 层级嵌套,让组件的接口更加简洁。这种方案适用于以下场景:
- 需要精确控制水印位置和大小
- 水印需要和笔迹在同一个 Canvas 上(用于截图)
- 组件封装时不方便使用 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 条件渲染是直接创建/销毁节点,没有过渡效果。
解决:使用 visibility 或 opacity 配合动画,而不是条件渲染:
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 篇:项目总结与优化方向 — 最后一篇,我们将回顾整体架构、总结关键技术决策,并探讨后续的功能增强、性能优化和发布准备。
更多推荐




所有评论(0)