HarmonyOS NEXT实战:手把手打造一款吉他和弦查询器
HarmonyOS NEXT实战:手把手打造一款吉他和弦查询器
从指板绘制到变调夹计算,带你用 Canvas 和 ArkTS 实现一个吉他手必备工具
前言
作为一名吉他爱好者,你是否遇到过这些场景?
- 练新歌时看不懂和弦图,百度半天找不到清晰的指法
- 变调夹上手之后,不知道夹在第几品该按什么和弦
- 想快速查一个和弦的指法,手机里却只有几张模糊的截图
作为一个开发者,与其被这些问题困扰,不如自己动手写一个。本文带你用 HarmonyOS NEXT 从零开发一款「吉他和弦查询器」,涵盖和弦库构建、Canvas 指板绘制、分类搜索、变调夹计算等核心技术点。
项目亮点:
- 内置 60+ 常用和弦数据,覆盖 9 大类和弦
- Canvas 绘制的吉他指板图,弦线粗细、圆点、横按一应俱全
- 分类筛选 + 模糊搜索,快速定位和弦
- 变调夹计算器,实时计算移调结果
- 和弦详情页,展示指板图、指法说明、组成音、同类型推荐
一、项目架构
1.1 功能模块
首页(Index)
├── 分类标签栏(大三/小三/属七... 横向滚动)
├── 搜索栏(弦名模糊搜索)
├── 和弦网格列表
│ └── 点击进入详情页
├── 和弦详情页(ChordDetail)
│ ├── 指板图(Canvas 绘制)
│ ├── 指法说明卡片
│ ├── 组成音展示
│ └── 同类型推荐
└── 变调夹计算器(CapoTool)
├── 目标音选择
├── 品格选择器
└── 计算结果 + 指法图
1.2 目录结构
entry/src/main/ets/
├── components/
│ └── FretboardView.ets # 吉他指板 Canvas 绘制组件
├── model/
│ ├── ChordData.ets # 数据类型定义 + 常量
│ └── ChordLibrary.ets # 和弦库数据 + 查询函数
└── pages/
├── Index.ets # 首页(和弦列表)
├── ChordDetail.ets # 和弦详情页
└── CapoTool.ets # 变调夹计算器
二、数据模型设计
2.1 和弦数据结构
一个吉他和弦需要用 6 根弦的品格位置和手指编号来描述:
指板排列:[E6, A5, D4, G3, B2, e1]
从6弦(粗) → 1弦(细)
export enum ChordType {
MAJOR = 'major',
MINOR = 'minor',
SEVENTH = '7th',
MAJ7 = 'maj7',
MIN7 = 'm7',
SUS = 'sus',
DIM = 'dim',
AUG = 'aug',
POWER = 'power'
}
export enum ChordCategory {
OPEN = '开放和弦',
BARRE = '大横按和弦',
JAZZ = '爵士和弦'
}
export interface BarreInfo {
fret: number; // 横按品格
startString: number; // 起始弦号
endString: number; // 结束弦号
}
export interface Chord {
name: string; // 和弦名称,如 "C"、"Am"、"G7"
type: ChordType; // 和弦类型
category: ChordCategory; // 分类
frets: number[]; // 6根弦的品格: -1=不弹, 0=空弦, 1+=品格
fingers: number[]; // 6根弦的手指: 0=不按, 1=食指, 2=中指, ...
barre?: BarreInfo; // 横按信息(可选)
notes?: string[]; // 每根弦的音名
description?: string; // 指法说明文字
groupTag?: string; // CAGED 分组标签
}
2.2 和弦类型标签
export const CHORD_TYPE_LABELS: Record<string, string> = {
[ChordType.MAJOR]: '大三和弦',
[ChordType.MINOR]: '小三和弦',
[ChordType.SEVENTH]: '属七和弦',
[ChordType.MAJ7]: '大七和弦',
[ChordType.MIN7]: '小七和弦',
[ChordType.SUS]: '挂留和弦',
[ChordType.DIM]: '减和弦',
[ChordType.AUG]: '增和弦',
[ChordType.POWER]: '强力和弦'
};
三、和弦库构建
3.1 工厂函数
使用工厂函数统一创建和弦对象,避免类型推断问题:
function chord(
name: string, type: ChordType, category: ChordCategory,
frets: number[], fingers: number[],
description: string,
notes?: string[],
barre?: BarreInfo
): Chord {
const r: Chord = { name, type, category, frets, fingers, description };
if (notes !== undefined) { r.notes = notes; }
if (barre !== undefined) { r.barre = barre; }
return r;
}
3.2 开放和弦示例
// C 和弦:6弦不弹,5弦3品,4弦2品,3弦空弦,2弦1品,1弦空弦
chord('C', ChordType.MAJOR, ChordCategory.OPEN,
[-1, 3, 2, 0, 1, 0], // frets
[0, 3, 2, 0, 1, 0], // fingers
'无名指5弦3品 · 中指4弦2品 · 食指2弦1品',
['-', 'C', 'E', 'G', 'C', 'E']),
// Am 和弦
chord('Am', ChordType.MINOR, ChordCategory.OPEN,
[-1, 0, 2, 2, 1, 0],
[0, 0, 2, 3, 1, 0],
'中指4弦2品 · 无名指3弦2品 · 食指2弦1品',
['-', 'A', 'E', 'A', 'C', 'E']),
// G7 和弦(带横按)
chord('F', ChordType.MAJOR, ChordCategory.BARRE,
[1, 1, 2, 3, 3, 1],
[1, 1, 2, 3, 4, 1],
'食指横按1品 · 中指3弦2品 · 无名指4弦3品 · 小指5弦3品',
['F', 'C', 'F', 'A', 'C', 'F'],
{ fret: 1, startString: 6, endString: 1 }),
3.3 完整和弦库
和弦库按类型分为 9 个分组:
| 分组 | 数量 | 示例 |
|---|---|---|
| 大三和弦 (Major) | 7 | C, D, E, F, G, A, B |
| 小三和弦 (Minor) | 7 | Am, Dm, Em, Bm, Fm, Gm, Cm |
| 属七和弦 (7th) | 7 | C7, D7, E7, G7, A7, B7, F7 |
| 大七和弦 (maj7) | 5 | Cmaj7, Dmaj7, Emaj7, Amaj7, Gmaj7 |
| 小七和弦 (m7) | 5 | Am7, Dm7, Em7, F#m7, Gm7 |
| 挂留和弦 (Sus) | 8 | Csus2, Csus4, Dsus2, Dsus4, Asus2… |
| 减和弦 (Dim) | 4 | Bdim, Ddim, G#dim, Edim |
| 增和弦 (Aug) | 3 | Caug, Eaug, Gaup |
| 强力和弦 (Power) | 6 | C5, D5, E5, G5, A5, F5 |
3.4 搜索与查询
/** 获取所有和弦(扁平列表) */
export function getAllChords(): Chord[] {
const result: Chord[] = [];
for (const group of ALL_GROUPS) {
for (const chord of group.chords) {
result.push(chord);
}
}
return result;
}
/** 按名称搜索和弦 */
export function searchChords(query: string): Chord[] {
const q = query.toLowerCase().trim();
if (!q) return [];
const all = getAllChords();
return all.filter(chord =>
chord.name.toLowerCase().includes(q) ||
(chord.description?.toLowerCase().includes(q))
);
}
四、首页:和弦分类列表
4.1 分类标签栏
使用横向滚动的 Scroll 实现可滑动的分类按钮:
// 分类标签(横向滚动)
Scroll() {
Row() {
// "全部"按钮
Text('All')
.fontColor(this.currentCategory === -1 ? '#FFF' : '#555')
.backgroundColor(this.currentCategory === -1 ? '#D14334' : '#EEE')
.borderRadius(16)
.onClick(() => { this.currentCategory = -1; this.updateDisplay(); })
ForEach(this.chordGroups, (group: ChordGroup, index: number) => {
Text(group.title.split(' ')[0]) // 只取英文名
.fontColor(this.currentCategory === index ? '#FFF' : '#555')
.backgroundColor(this.currentCategory === index ? '#2B5F8A' : '#EEE')
.borderRadius(16)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => {
this.currentCategory = index;
this.searchQuery = '';
this.updateDisplay();
})
})
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
4.2 搜索栏
搜索框点击后展开完整搜索模式:
Row() {
if (this.showSearch) {
TextInput({ placeholder: '搜索和弦名称 (C, Am, G7...)', text: this.searchQuery })
.layoutWeight(1)
.height(40)
.borderRadius(20)
.onChange((val: string) => {
this.searchQuery = val;
this.updateDisplay();
})
Text('取消')
.onClick(() => {
this.showSearch = false;
this.searchQuery = '';
this.updateDisplay();
})
} else {
TextInput({ placeholder: '搜索和弦...', text: this.searchQuery })
.layoutWeight(1)
.borderRadius(20)
.onClick(() => { this.showSearch = true; })
}
}
4.3 和弦卡片
每个和弦卡片显示:和弦名称、类型标签、品格缩略预览:
@Builder
ChordCard(item: Chord) {
Row() {
// 和弦名称(大号)
Column() {
Text(item.name).fontSize(22).fontWeight(FontWeight.Bold)
Text(CHORD_TYPE_LABELS[item.type] ?? '').fontSize(11).fontColor('#888')
}
.width(72)
.alignItems(HorizontalAlign.Center)
// 简要指法预览(用 O/X/数字缩略显示品格位置)
Column() {
Row() {
ForEach(item.frets, (fret: number, idx: number) => {
Text(this.fretSymbol(fret))
.fontSize(11)
.fontColor(this.fretColor(fret))
.width(20)
})
}
Text(item.description ?? '').fontSize(10).maxLines(1)
}
.layoutWeight(1)
Text('›').fontSize(22).fontColor('#CCC')
}
.backgroundColor('#FFF')
.borderRadius(12)
.onClick(() => {
router.pushUrl({
url: 'pages/ChordDetail',
params: { chordName: item.name, chordType: item.type }
});
})
}
4.4 底部导航
Row() {
// 和弦查询 Tab
Column() {
Text('🎸').fontSize(20)
Text('和弦查询').fontSize(11).fontColor('#D14334')
}
.layoutWeight(1)
// 变调夹计算 Tab
Column() {
Text('🎛️').fontSize(20)
Text('变调夹计算').fontSize(11).fontColor('#888')
}
.layoutWeight(1)
.onClick(() => {
router.pushUrl({ url: 'pages/CapoTool' });
})
}
.width('100%')
.height(56)
.backgroundColor('#FFF')
.borderRadius({ topLeft: 16, topRight: 16 })
.shadow({ radius: 8, color: '#10000000', offsetY: -2 })
五、核心挑战:Canvas 指板图绘制
这是本应用的技术亮点——使用 Canvas 2D API 绘制吉他指板图。
5.1 布局计算
@Component
export struct FretboardView {
@Prop chord: Chord;
@Prop startFret: number = 1;
// 布局常量
private readonly PADDING_LEFT: number = 36;
private readonly PADDING_RIGHT: number = 16;
private readonly PADDING_TOP: number = 42;
private readonly PADDING_BOTTOM: number = 20;
private readonly NUT_WIDTH: number = 6;
// 颜色常量
private readonly COLOR_STRINGS: string = '#999999';
private readonly COLOR_FRET: string = '#888888';
private readonly COLOR_DOT_FILL: string = '#2B5F8A';
private readonly COLOR_DOT_ROOT: string = '#D14334';
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D();
关键布局计算:
stringSpacing = 画布可用宽度 / 5
fretSpacing = 画布可用高度 / 5
弦线从6弦(粗)到1弦(细):线宽 [3.2, 2.8, 2.4, 2.0, 1.6, 1.2]
品丝从0(上枕)到5品共6条横线
5.2 绘制步骤
完整的绘制流程分为 7 步:
drawFretboard(): void {
const ctx = this.context;
const w = ctx.width;
const h = ctx.height;
// 1. 清空 + 背景
ctx.clearRect(0, 0, w, h);
this.roundRect(ctx, 0, 0, w, h, 12);
ctx.fillStyle = '#FFF9F0';
ctx.fill();
// 2. 绘制品丝(横线)
for (let f = 0; f <= 5; f++) {
const y = pt + f * fretSpacing;
ctx.moveTo(pl, y);
ctx.lineTo(pl + drawW, y);
}
// 3. 绘制上枕(第一根横线加粗)
ctx.lineWidth = this.NUT_WIDTH; // 6px 粗线
ctx.moveTo(pl, pt);
ctx.lineTo(pl + drawW, pt);
// 4. 绘制弦线(竖线,粗细不同)
const stringWidths = [3.2, 2.8, 2.4, 2.0, 1.6, 1.2];
for (let s = 0; s < 6; s++) {
ctx.lineWidth = stringWidths[s];
ctx.moveTo(x, pt);
ctx.lineTo(x, pt + drawH);
}
// 5. 绘制手指圆点
for (let s = 0; s < 6; s++) {
if (fret <= 0) continue; // 跳过空弦和不弹
ctx.arc(x, y, dotRadius, 0, Math.PI * 2);
ctx.fillStyle = isRoot ? '#D14334' : '#2B5F8A';
ctx.fill();
// 指法编号 1~4
ctx.fillStyle = '#FFF';
ctx.fillText(fingers[s].toString(), x, y);
}
// 6. 绘制横按指示(圆弧矩形 + 数字1)
if (chord.barre) {
this.roundRect(ctx, xStart - r, yBarre - r, width, r * 2, r);
ctx.fillStyle = '#2B5F8A';
ctx.fill();
ctx.fillStyle = '#FFF';
ctx.fillText('1', (xStart + xEnd) / 2, yBarre);
}
// 7. 绘制空弦O / 不弹X + 音名
for (let s = 0; s < 6; s++) {
if (frets[s] === 0) {
ctx.arc(x, y, 7, 0, Math.PI * 2);
ctx.strokeStyle = '#2B5F8A'; // 空心圆 O
} else if (frets[s] === -1) {
ctx.moveTo(x-5, y-5); ctx.lineTo(x+5, y+5); // 打叉 X
ctx.moveTo(x+5, y-5); ctx.lineTo(x-5, y+5);
}
}
}
5.3 圆角矩形工具函数
ArkUI 的 Canvas API 没有内置 roundRect,需要自己实现:
roundRect(ctx: CanvasRenderingContext2D, x: number, y: number,
w: number, h: number, r: number): void {
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
5.4 注意事项
踩坑记录:
- Canvas 必须在
onReady()回调中绘制,此时ctx.width/height才有效 - 尺寸变化时要重绘,绑定
onAreaChange事件 - 使用
setTimeout(fn, 50)延迟重绘,确保布局已刷新
Canvas(this.context)
.width('100%')
.aspectRatio(1.0)
.constraintSize({ maxHeight: 340 })
.backgroundColor('#FFF9F0')
.borderRadius(12)
.onReady(() => {
this.drawFretboard();
})
.onAreaChange(() => {
setTimeout(() => {
this.drawFretboard();
}, 50);
});
六、和弦详情页
6.1 参数传递
使用 router.pushUrl 传递和弦名称和类型:
router.pushUrl({
url: 'pages/ChordDetail',
params: {
chordName: item.name,
chordType: item.type
}
});
6.2 和弦信息展示
详情页包含 5 个区域:
Scroll() {
Column() {
// 1. 和弦名称 + 类型标签
Row() {
Text(this.chord.name).fontSize(36)
Text(CHORD_TYPE_LABELS[this.chord.type] ?? '')
.backgroundColor(分类对应颜色)
.borderRadius(10)
}
// 2. 指板图
FretboardView({ chord: this.chord, startFret: ... })
// 3. 指法说明卡片
Column() {
Text('🎯 指法说明')
Text(this.chord.description ?? '')
if (this.chord.barre) {
Text(`横按: 食指按第${barre.fret}品`)
}
// 指法表
Row() {
ForEach(this.chord.frets, (fret, idx) => {
Text(['6弦','5弦','4弦','3弦','2弦','1弦'][idx])
Text(fret符号)
})
}
}
// 4. 组成音展示
Column() {
Text('🎵 组成音')
ForEach(this.chord.notes, (note) => {
Text(note).backgroundColor('#EBF2FA')
})
}
// 5. 同类型推荐
Column() {
Text('📋 其他' + 类型名)
Scroll() {
Row() {
ForEach(this.relatedChords, (related: Chord) => {
Column() {
Text(related.name).fontSize(18)
Text(简写标签).fontSize(10)
}
.onClick(() => { /* 跳转到选中和弦 */ })
})
}
}
}
}
}
6.3 同类型推荐
// 查找同类型其他和弦
this.relatedChords = all.filter(c =>
c.type === this.chord?.type && c.name !== this.chord?.name
).slice(0, 6);
七、变调夹计算器
7.1 变调夹原理
变调夹是吉他手常用的工具——夹在第 N 品时,原本按 C 和弦的指法,实际发出的声音升高了 N 个半音。
计算公式:
实际弹奏和弦 = 目标音和弦的根音 - N 个半音
示例:想弹出 C 大调的声音,夹 3 品 → 实际弹 A 和弦
因为 C - 3半音 = A
export function applyCapo(chordName: string, capoFret: number): string {
const noteNames: string[] = [
'C', 'C#', 'D', 'D#', 'E', 'F',
'F#', 'G', 'G#', 'A', 'A#', 'B'
];
// 分离根音和后缀(如 "Cmaj7" → "C" + "maj7")
const rootMatch = chordName.match(/^([A-G][#b]?)(.*)$/);
if (rootMatch === null) return chordName;
let root = rootMatch[1];
const suffix = rootMatch[2];
// 降号转升号
if (root.includes('b')) {
const flatMap: Record<string, string> = {
'Bb': 'A#', 'Db': 'C#', 'Eb': 'D#', 'Gb': 'F#', 'Ab': 'G#'
};
root = flatMap[root] ?? root;
}
const rootIndex = noteNames.indexOf(root);
if (rootIndex === -1) return chordName;
// 根音向后移动 N 个半音
const actualIndex = (rootIndex - capoFret + 12) % 12;
return noteNames[actualIndex] + suffix;
}
7.2 交互设计
@Component
struct CapoTool {
@State selectedRoot: string = 'C';
@State selectedType: ChordType = ChordType.MAJOR;
@State capoFret: number = 3;
@State resultChordName: string = '';
@State resultChord: Chord | null = null;
calculateResult(): void {
const fullName = this.selectedRoot + CHORD_SHORT_SUFFIX(this.selectedType);
this.resultChordName = applyCapo(fullName, this.capoFret);
// 在库中查找计算结果
this.resultChord = all.find(c => c.name === this.resultChordName);
}
}
7.3 UI 布局
变调夹计算器分三个区域:
Scroll() {
Column() {
// Section 1: 目标音和弦(根音选择 + 类型选择)
Column() {
Text('🎯 目标音和弦(你想听到的声音)')
// 根音选择:C, C#, D... 共12个圆形按钮
Scroll() {
Row() {
ForEach(this.rootNotes, (note: string) => {
Text(note)
.width(40).height(40).borderRadius(20)
.backgroundColor(note === this.selectedRoot ? '#D14334' : '#EEE')
.onClick(() => { this.onRootChange(note); })
})
}
}
// 类型选择:Major / Minor / 7th...
Row() {
ForEach(this.chordTypeList, (item: ChordTypeItem) => {
Text(item.label)
.backgroundColor(item.type === this.selectedType ? '#2B5F8A' : '#EEE')
.borderRadius(18)
.onClick(() => { this.onTypeChange(item.type); })
})
}
}
// Section 2: 变调夹位置(1-12品网格)
Column() {
Text('📌 变调夹位置')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach([1..12], (fret: number) => {
Text(fret.toString())
.backgroundColor(fret === this.capoFret ? '#D14334' : '#F5F5F5')
.borderRadius(6)
.onClick(() => { this.onCapoChange(fret); })
})
}
}
// Section 3: 计算结果 + 指法图
Column() {
Text('✅ 计算结果')
// 公式展示
Text(`${C} → 夹 3 品 → 弹 A`)
// 如果有对应的和弦指法图
if (this.resultChord) {
FretboardView({ chord: this.resultChord, startFret: ... })
}
}
}
}
八、页面路由与导航
8.1 页面注册
在 main_pages.json 中注册所有页面:
{
"src": [
"pages/Index",
"pages/ChordDetail",
"pages/CapoTool"
]
}
8.2 路由跳转
import { router } from '@kit.ArkUI';
// 跳转到详情页(带参数)
router.pushUrl({
url: 'pages/ChordDetail',
params: {
chordName: item.name,
chordType: item.type
}
});
// 在详情页接收参数
aboutToAppear(): void {
const params = router.getParams() as Record<string, Object>;
this.chordName = params['chordName'] as string;
this.chordType = params['chordType'] as ChordType;
}
// 返回上一页
router.back();
九、项目配置
9.1 app.json5
{
"app": {
"bundleName": "com.example.chordfinder",
"versionCode": 1000000,
"versionName": "1.0.0",
"icon": "$media:layered_image",
"label": "$string:app_name"
}
}
9.2 module.json5
{
"module": {
"name": "entry",
"type": "entry",
"deviceTypes": ["phone"],
"pages": "$profile:main_pages",
"abilities": [{
"name": "EntryAbility",
"exported": true,
"skills": [{
"actions": ["ohos.want.action.home"],
"entities": ["entity.system.home"]
}]
}]
}
}
十、关键技术总结
10.1 Canvas 指板绘制
| 要素 | 实现方式 |
|---|---|
| 6根弦线 | 竖直线,宽度递减 [3.2, 2.8, 2.4, 2.0, 1.6, 1.2] |
| 品丝 | 横线,第一根加粗作为上枕 |
| 手指圆点 | arc() 绘制实心圆,根音红色/其他蓝色 |
| 指法编号 | fillText() 在圆点中心显示 1~4 |
| 横按指示 | 弧线矩形 + “1” 字 |
| 空弦/不弹标记 | 空心圆 O / 打叉 X |
10.2 和弦数据建模
frets: number[] // 6个数字,每个代表品格位置
fingers: number[] // 6个数字,每个代表手指编号
barre?: BarreInfo // { fret, startString, endString }
-1 = 不弹该弦
0 = 空弦
1+ = 按在第 N 品
10.3 变调夹算法
目标和弦根音 - 变调夹品格 = 实际弹奏根音
降号转升号处理确保索引查找正确。
10.4 搜索实现
模糊搜索支持:
- 按和弦名称(C、Am、G7…)
- 按指法描述关键词
十一、运行效果展示


十二、扩展方向
- 音频播放:点击和弦播放对应声音
- 指法动画:逐弦演示按弦顺序
- 更多和弦:扩展爵士和弦、九和弦、十一和弦
- 曲谱模式:显示常用歌曲的和弦进行
- 调音器:内置半音阶调音功能
- 自定义和弦:用户在指板上点击创建自定义和弦
- CAGED 系统:可视化展示 CAGED 系统五种指型
结语
这个项目涵盖了 HarmonyOS 开发的多个核心技术点:
- Canvas 2D API:指板图绘制(圆形、圆弧、文字、粗细线条)
- 数据建模:和弦数据结构的抽象与工厂函数
- 状态管理:@State、@Prop、@Watch 的响应式更新
- 路由导航:router.pushUrl 参数传递与 router.back
- 手势交互:点击跳转、搜索输入、分类筛选
- 算法实现:变调夹移调计算
对于吉他手来说,这是一个实用的日常工具;对于开发者来说,这是一个完美的 Canvas 绘图入门项目。代码已在文中完整呈现,遇到问题欢迎交流!
技术栈:HarmonyOS NEXT + ArkTS + Canvas 2D
开发环境:DevEco Studio 5.0+ / SDK API 23
和弦数量:60+(9大类)
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)