在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

一、应用背景与设计思路

1.1 为什么选择诗词鉴赏作为演示主题

中国古典诗词是中华文化最璀璨的明珠之一。从《诗经》的风雅颂到唐诗宋词元曲,数千年的诗词文化积淀了无数脍炙人口的佳作。在移动互联网时代,通过鸿蒙原生应用(HarmonyOS Native App)来呈现和传播诗词文化,既是对传统文化的数字活化,也是展示ArkTS开发能力的绝佳场景。

诗词内容具有天然的「分类展示」和「列表浏览」双重需求:读者既希望按诗人、朝代对诗词进行分类概览(适合Grid网格布局),又希望逐篇阅读诗词全文并欣赏赏析(适合List列表布局)。这种数据呈现的双重性,使得"诗词集"应用成为演示ArkTS中List和Grid布局配合使用的最佳案例。

1.2 从用户需求出发的UX设计思考

在设计诗词鉴赏应用之前,我们需要深入理解目标用户群体的行为模式和使用场景。根据对传统文化爱好者和移动端阅读用户的调研分析,可以归纳出三类典型用户画像:

第一类:诗词深度爱好者
这类用户对诗词有较深的研究,他们关注诗词的版本考据、不同注家的注释差异、以及诗词在历代文论中的评价。他们使用应用的主要诉求是:快速找到某个诗人的全部作品,对比同一题材的不同创作,以及查阅详细的考据性注释。对于这类用户,Grid诗人网格提供了高效的导航入口,List列表按体裁排序满足了系统性阅读的需求,而详情页的多Tab结构则让注释、翻译、赏析有序排布,互不干扰。

第二类:学生与教育场景
中小学生和大学生是诗词学习的主力群体。他们需要快速查找课内要求的背诵篇目,理解诗词的字词含义和艺术手法,并通过朗读帮助记忆。对于这类用户,TTS语音朗读功能尤为重要——研究表明,多感官(视觉+听觉)输入可以提升记忆效率约30%。同时,诗词详情页的"注释"Tab为学生提供了精准的字词解释,"翻译"Tab提供了白话文对照,帮助学生跨越古今语言的鸿沟。

第三类:泛文化兴趣用户
这类用户对诗词没有系统性学习的压力,更多是为了日常的文化熏陶和生活美学的提升。他们可能在地铁通勤时随手翻阅一首诗,在睡前听一段诗词朗读,或者在社交场合需要引用一句诗词时快速查找。对于这类用户,应用界面必须做到简洁美感和低学习成本:Grid卡片让选择变成"点"而不是"找",TTS一键朗读让内容消费变得轻松,收藏功能则方便他们随时回看曾经打动过自己的篇目。

基于这三类用户画像,我们最终确定了"Grid概览→List浏览→Detail精读→TTS伴听"的四层交互链路,兼顾了浏览效率、阅读深度和感官体验。

1.3 应用功能概述

本文将要构建的"鸿蒙ArkTS诗词集"应用包含以下核心功能:

  1. 诗人分类网格:以Grid网格布局展示历代著名诗人卡片(李白、杜甫、苏轼、李清照等),每张卡片包含诗人头像、姓名、朝代和代表作数量
  2. 诗词列表展示:点击诗人卡片后,以List列表展示该诗人的代表作,包含诗词标题、体裁、创作背景摘要
  3. 诗词详情阅读:点击列表项进入诗词全文阅读界面,支持原文、注释、翻译、赏析多Tab切换
  4. TTS语音朗读:集成HarmonyOS TextToSpeech能力,实现诗词的语音播报
  5. 搜索与收藏:支持诗词标题/诗句关键词搜索,以及个人收藏管理

1.3 技术栈概览

技术领域 选用方案 说明
开发语言 ArkTS 鸿蒙原生声明式开发语言
布局框架 List + Grid 双布局联动 核心演示内容
数据管理 本地JSON + @State/@Prop 无需后端服务
语音能力 @ohos.multimedia.textToSpeech TTS语音合成
路由导航 @ohos.router 页面间跳转
UI组件 ArkUI原生组件 Button/Text/Image等

二、ArkTS项目环境搭建

2.1 创建HarmonyOS项目

在DevEco Studio中创建新项目时,选择「Empty Ability」模板,确保SDK版本为API 10及以上(推荐API 11以支持最新的TextToSpeech API)。

项目基础配置:

  • Bundle name:com.example.poetrycollection
  • Module类型:entry(默认)
  • 编译版本:ArkTS 3.2+

2.2 项目目录结构设计

为了保持代码的清晰性,我们按照以下结构组织源码:

entry/src/main/ets/
├── pages/
│   ├── index.ets          // 主页面(诗人网格 + 诗词列表)
│   ├── PoemDetailPage.ets // 诗词详情页
│   └── SearchPage.ets     // 搜索页面
├── data/
│   ├── poets.json         // 诗人数据
│   └── poems.json         // 诗词数据
├── model/
│   ├── PoetModel.ets      // 诗人数据模型
│   └── PoemModel.ets      // 诗词数据模型
├── service/
│   └── TtsService.ets     // TTS语音服务封装
└── common/
    └── constants.ets      // 常量与主题定义

2.3 配置文件修改

module.json5 中确认我们不需要额外权限(TTS在API 11以上默认可用),在 build-profile.json5 中确保兼容设备类型包括phone和tablet。


三、数据模型设计

3.1 诗人数据模型(PoetModel)

诗人数据是整个应用的索引骨架,每个诗人包含以下字段:

// model/PoetModel.ets
export interface Poet {
  id: number;            // 唯一标识
  name: string;          // 姓名(如"李白")
  dynasty: string;       // 朝代(如"唐代")
  avatar: string;        // 头像资源路径
  description: string;   // 生平简介
  poemCount: number;     // 收录诗词数量
  styleTags: string[];   // 风格标签(如"豪放""浪漫")
}

export class PoetModel {
  static getAllPoets(): Poet[] {
    return [
      {
        id: 1,
        name: '李白',
        dynasty: '唐代',
        avatar: '/images/poets/libai.png',
        description: '字太白,号青莲居士,唐代伟大的浪漫主义诗人,被后人誉为"诗仙"',
        poemCount: 6,
        styleTags: ['豪放', '浪漫', '飘逸']
      },
      {
        id: 2,
        name: '杜甫',
        dynasty: '唐代',
        avatar: '/images/poets/dufu.png',
        description: '字子美,自号少陵野老,唐代伟大的现实主义诗人,被后人誉为"诗圣"',
        poemCount: 6,
        styleTags: ['沉郁', '现实主义', '爱国']
      },
      {
        id: 3,
        name: '苏轼',
        dynasty: '宋代',
        avatar: '/images/poets/sushi.png',
        description: '字子瞻,号东坡居士,宋代文学最高成就的代表',
        poemCount: 6,
        styleTags: ['旷达', '豪放', '哲理']
      },
      {
        id: 4,
        name: '李清照',
        dynasty: '宋代',
        avatar: '/images/poets/liqingzhao.png',
        description: '号易安居士,宋代婉约词派代表,有"千古第一才女"之称',
        poemCount: 6,
        styleTags: ['婉约', '细腻', '感伤']
      },
      {
        id: 5,
        name: '白居易',
        dynasty: '唐代',
        avatar: '/images/poets/baijuyi.png',
        description: '字乐天,号香山居士,唐代伟大的现实主义诗人',
        poemCount: 6,
        styleTags: ['通俗', '写实', '讽喻']
      },
      {
        id: 6,
        name: '王维',
        dynasty: '唐代',
        avatar: '/images/poets/wangwei.png',
        description: '字摩诘,号摩诘居士,唐代山水田园诗人代表',
        poemCount: 6,
        styleTags: ['空灵', '禅意', '山水']
      },
      {
        id: 7,
        name: '李商隐',
        dynasty: '唐代',
        avatar: '/images/poets/lishangyin.png',
        description: '字义山,号玉溪生,晚唐著名诗人',
        poemCount: 4,
        styleTags: ['隐晦', '唯美', '深情']
      },
      {
        id: 8,
        name: '辛弃疾',
        dynasty: '宋代',
        avatar: '/images/poets/xinqiji.png',
        description: '字幼安,号稼轩,宋代豪放派词人代表',
        poemCount: 4,
        styleTags: ['豪放', '爱国', '悲壮']
      }
    ];
  }
}

3.2 诗词数据模型(PoemModel)

每首诗词包含完整的文本、注释、翻译和赏析信息,为TTS播报提供结构化数据:

// model/PoemModel.ets
export interface Poem {
  id: number;
  poetId: number;         // 所属诗人ID
  title: string;          // 标题
  genre: string;          // 体裁(五言律诗/七言绝句/词/古风)
  dynasty: string;        // 创作朝代
  background: string;     // 创作背景(短摘要)
  content: string;        // 正文(用于TTS朗读)
  notes: Note[];          // 注释
  translation: string;    // 白话翻译
  appreciation: string;   // 赏析
  isFavorite: boolean;    // 是否收藏
}

export interface Note {
  keyword: string;
  explanation: string;
}

3.3 诗词数据示例

选取八位诗人的代表性作品,共计四十四首诗词,覆盖唐诗宋词主流名篇:

李白代表作

  • 《静夜思》— 床前明月光
  • 《望庐山瀑布》— 日照香炉生紫烟
  • 《将进酒》— 君不见黄河之水天上来
  • 《早发白帝城》— 朝辞白帝彩云间
  • 《行路难》— 金樽清酒斗十千
  • 《黄鹤楼送孟浩然之广陵》— 故人西辞黄鹤楼

杜甫代表作

  • 《春望》— 国破山河在
  • 《茅屋为秋风所破歌》— 八月秋高风怒号
  • 《登高》— 风急天高猿啸哀
  • 《春夜喜雨》— 好雨知时节
  • 《望岳》— 岱宗夫如何
  • 《江南逢李龟年》— 岐王宅里寻常见

苏轼代表作

  • 《水调歌头·明月几时有》— 明月几时有
  • 《念奴娇·赤壁怀古》— 大江东去
  • 《题西林壁》— 横看成岭侧成峰
  • 《饮湖上初晴后雨》— 水光潋滟晴方好
  • 《江城子·密州出猎》— 老夫聊发少年狂
  • 《定风波》— 莫听穿林打叶声

李清照代表作

  • 《如梦令·常记溪亭日暮》— 常记溪亭日暮
  • 《声声慢·寻寻觅觅》— 寻寻觅觅
  • 《一剪梅·红藕香残玉簟秋》— 红藕香残玉簟秋
  • 《醉花阴·薄雾浓云愁永昼》— 薄雾浓云愁永昼
  • 《武陵春·春晚》— 风住尘香花已尽
  • 《夏日绝句》— 生当作人杰

白居易代表作:《赋得古原草送别》《忆江南》《琵琶行》《钱塘湖春行》《问刘十九》《大林寺桃花》

王维代表作:《使至塞上》《山居秋暝》《鸟鸣涧》《送元二使安西》《九月九日忆山东兄弟》《画》

李商隐代表作:《锦瑟》《夜雨寄北》《无题·相见时难别亦难》《登乐游原》

辛弃疾代表作:《青玉案·元夕》《破阵子·为陈同甫赋壮词以寄之》《西江月·夜行黄沙道中》《丑奴儿·书博山道中壁》


四、主页面布局设计(index.ets)

4.1 页面整体架构

主页面采用"上下结构",上部是诗人网格(Grid),下部是诗词列表(List),两者联动——点击诗人则下方列表自动过滤显示该诗人作品。

层级关系如下:

Column(全屏)
├── 搜索栏(固定在顶部)
├── 诗人网格区(Grid,占据上半部分)
│   ├── 诗人卡片1
│   ├── 诗人卡片2
│   ├── 诗人卡片3
│   ├── 诗人卡片4
│   └── 诗人卡片5-8(第二行)
├── 分隔区(TTS控制按钮 + 诗人名称指示)
└── 诗词列表区(List,占据下半部分)
    ├── 诗词列表项1
    ├── 诗词列表项2
    └── 诗词列表项3-6

4.2 Grid诗人卡片实现

Grid布局使用 GridItem 作为网格单元,每行2列。每张诗人卡片包含头像、姓名、朝代、代表作数量和风格标签:

// index.ets 中的 Grid 布局部分
Grid() {
  ForEach(this.poets, (poet: Poet, index: number) => {
    GridItem() {
      Column() {
        // 诗人头像(圆形裁剪)
        Image($rawfile(poet.avatar))
          .width(60)
          .height(60)
          .borderRadius(30)
          .objectFit(ImageFit.Cover)

        // 诗人姓名
        Text(poet.name)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 8 })

        // 朝代标签
        Text(poet.dynasty)
          .fontSize(12)
          .fontColor('#8B7355')
          .margin({ top: 2 })

        // 代表作数量
        Text(`收录${poet.poemCount}`)
          .fontSize(11)
          .fontColor('#999')
          .margin({ top: 2 })

        // 风格标签区
        Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
          ForEach(poet.styleTags, (tag: string) => {
            Text(tag)
              .fontSize(10)
              .fontColor('#8B4513')
              .backgroundColor('#FFF8E7')
              .borderRadius(8)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .margin(2)
          })
        }
        .margin({ top: 6 })

        // 选中高亮指示
        if (this.selectedPoetId === poet.id) {
          Text('已选')
            .fontSize(10)
            .fontColor('#FFFFFF')
            .backgroundColor('#8B4513')
            .borderRadius(10)
            .padding({ left: 8, right: 8, top: 2, bottom: 2 })
            .margin({ top: 4 })
        }
      }
      .width('100%')
      .padding(12)
      .backgroundColor(this.selectedPoetId === poet.id ? '#FFF5E6' : '#FAFAFA')
      .borderRadius(12)
      .shadow({ radius: 4, color: '#20000000', offsetX: 0, offsetY: 2 })
      .onClick(() => this.selectPoet(poet.id))
    }
  }, (item: Poet) => item.id.toString())
}
.columnsTemplate('1fr 1fr')
.rowsTemplate('1fr 1fr 1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.padding(16)

关键设计点

  1. .columnsTemplate('1fr 1fr') 实现2列自适应1fr 表示等分可用宽度,无论屏幕宽度是360vp还是800vp,两列自动平分
  2. 选中状态管理selectedPoetId 跟踪当前选中的诗人,通过背景色变化和"已选"角标提供清晰的交互反馈
  3. 阴影与圆角:每张卡片使用 shadowborderRadius 营造层次感,符合Material Design设计语言

4.3 List诗词列表实现

列表部分展示被选中诗人的所有代表作,每项包含诗词标题、体裁标签、创作背景摘要和收藏按钮:

// index.ets 中的 List 布局部分
List({ space: 10 }) {
  ForEach(this.filteredPoems, (poem: Poem, index: number) => {
    ListItem() {
      Row() {
        // 序号列
        Text(`${index + 1}`)
          .fontSize(14)
          .fontColor('#8B4513')
          .fontWeight(FontWeight.Bold)
          .width(30)
          .textAlign(TextAlign.Center)

        // 正文信息区域
        Column() {
          // 上方行:标题 + 体裁标签
          Row() {
            Text(poem.title)
              .fontSize(16)
              .fontWeight(FontWeight.Medium)
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
              .layoutWeight(1)

            Text(poem.genre)
              .fontSize(10)
              .fontColor('#8B4513')
              .backgroundColor('#FFF0D0')
              .borderRadius(6)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          }

          // 创作背景
          Text(poem.background)
            .fontSize(12)
            .fontColor('#999')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .margin({ top: 4 })

          // 底部行:名句预览
          Text(poem.content.length > 30 ?
            poem.content.substring(0, 28) + '...' :
            poem.content)
            .fontSize(13)
            .fontColor('#666')
            .italic(true)
            .margin({ top: 4 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)

        // 右侧收藏按钮
        Image($rawfile(poem.isFavorite ? 'icon_filled.png' : 'icon_outline.png'))
          .width(24)
          .height(24)
          .onClick(() => this.toggleFavorite(poem.id))
      }
      .width('100%')
      .padding(14)
      .backgroundColor('#FAFAFA')
      .borderRadius(10)
      .onClick(() => this.openPoemDetail(poem))
    }
  })
}
.width('100%')
.layoutWeight(1)

List布局的关键参数

参数 说明
space 10 列表项间距10vp
layoutWeight 1 占据剩余所有空间
ListItem 内置 Column 支持垂直多行文本 自动换行 每项可展示标题、背景、名句三行

4.4 Grid与List联动逻辑

这是整个页面最核心的设计——当用户在Grid中选择一位诗人,List自动刷新为该诗人的全部作品:

// 页面级状态变量
@State selectedPoetId: number = 1;  // 默认选中李白
@State poets: Poet[] = [];
@State poems: Poem[] = [];

// 计算属性:过滤后的诗词列表
get filteredPoems(): Poem[] {
  return this.poems.filter(p => p.poetId === this.selectedPoetId);
}

// 选中诗人的回调
selectPoet(poetId: number): void {
  if (this.selectedPoetId === poetId) return;  // 已选中则忽略
  this.selectedPoetId = poetId;
}

为什么选用 computed getter 而非 @State?
因为 filteredPoems 是一个派生数据——它完全由 selectedPoetIdpoems 数组共同决定。使用 get 方法而非额外声明 @State 可以避免状态冗余,ArkTS的响应式系统会在依赖变化时自动触发UI刷新。

4.5 交互反馈与动画设计

在List+Grid双布局中,良好的交互反馈是提升用户体验的关键。我们在应用中实现了以下反馈机制:

点击涟漪效果:每个GridItem和ListItem在点击时都有水波纹扩散效果。ArkTS中通过 .onClick 结合状态变量实现按下态样式变化,模拟Material Design的涟漪反馈。

选中状态渐变:当用户从一位诗人切换到另一位时,Grid卡片的背景色从 #FAFAFA 渐变为 #FFF5E6。虽然ArkTS当前不支持自动插值动画在 backgroundColor 上(需要通过 animateTo 实现),但我们通过设置 Transition 样式来确保切换的平滑感:

animateTo({ duration: 200, curve: Curve.EaseInOut }, () => {
  this.selectedPoetId = newId;
});

Grid滚动复位:当用户选择诗人后,下方的List列表自动滚动到顶部。这确保了用户看到的是列表开头而非中间,符合信息消费的"从头阅读"直觉:

// 在 selectPoet 方法中
this.scroller.scrollTo({ xOffset: 0, yOffset: 0, animation: { duration: 300 } });

TTS状态可视化:朗读过程中,列表中的当前朗读项会高亮显示,并在标题前添加一个"♪"播放图标,让用户能清晰感知当前的朗读进度。

这些微交互虽然看似细小,但构成了应用"手感"的核心。一个成功的应用与普通应用之间的差距,往往不在于功能的多寡,而在于这些细节打磨的深度。

在Grid和List之间加入一个控制栏,提供当前选中诗人的名称和TTS朗读控制:

// 分隔控制栏
Row() {
  // 左侧:当前诗人和诗词数
  Text(`${this.selectedPoet.name} · ${this.filteredPoems.length}`)
    .fontSize(14)
    .fontColor('#8B4513')
    .fontWeight(FontWeight.Bold)

  Blank()

  // 右侧:TTS控制
  if (this.isPlaying) {
    Button('停止朗读')
      .fontSize(12)
      .fontColor('#FFFFFF')
      .backgroundColor('#D32F2F')
      .borderRadius(16)
      .padding({ left: 12, right: 12 })
      .onClick(() => this.stopTts())
  } else {
    Button('朗读当前诗人全部作品')
      .fontSize(12)
      .fontColor('#FFFFFF')
      .backgroundColor('#8B4513')
      .borderRadius(16)
      .padding({ left: 12, right: 12 })
      .onClick(() => this.startTts())
  }
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor('#FFF8E7')

五、TTS语音播报集成

5.1 TTS服务封装

依据华为鸿蒙开发者文档中TextToSpeech的API,我们将TTS能力封装为一个独立的Service类,便于在多处复用:

// service/TtsService.ets
import textToSpeech from '@ohos.multimedia.textToSpeech';

export class TtsService {
  private engine: textToSpeech.TextToSpeechEngine | null = null;
  private isInitialized: boolean = false;

  // 初始化TTS引擎
  async initialize(): Promise<boolean> {
    try {
      const engineConfig: textToSpeech.EngineInfo = {
        engineType: textToSpeech.EngineType.ENGINE_TYPE_LOCAL,
        speakType: textToSpeech.SpeakType.SPEAK_TYPE_AUDIO
      };

      this.engine = await textToSpeech.createEngine(engineConfig);

      if (this.engine) {
        this.isInitialized = true;
        return true;
      }
      return false;
    } catch (error) {
      console.error(`TTS初始化失败: ${JSON.stringify(error)}`);
      return false;
    }
  }

  // 设置语音参数
  async setSpeechParams(): Promise<void> {
    if (!this.engine) return;

    // 设置朗读速度(稍慢,适合诗词)
    const speedParam: textToSpeech.SpeechParams = {
      speed: 75  // 默认100,75%速度更适合诗词朗读
    };
    await this.engine.setParams(speedParam);

    // 设置音调
    const pitchParam: textToSpeech.SpeechParams = {
      pitch: 100
    };
    await this.engine.setParams(pitchParam);
  }

  // 朗读单首诗词
  async speakPoem(poemTitle: string, poemContent: string): Promise<void> {
    if (!this.engine || !this.isInitialized) {
      await this.initialize();
      await this.setSpeechParams();
    }

    const text = `${poemTitle}${poemContent}`;
    const utteranceId = Date.now().toString();

    try {
      await this.engine?.speak(text, utteranceId);
    } catch (error) {
      console.error(`TTS朗读失败: ${JSON.stringify(error)}`);
    }
  }

  // 朗读多首诗词(顺序朗读)
  async speakPoems(poems: Array<{ title: string; content: string }>): Promise<void> {
    for (const poem of poems) {
      await this.speakPoem(poem.title, poem.content);
      // 每首诗词之间间隔2秒
      await this.delay(2000);
    }
  }

  // 暂停朗读
  pause(): void {
    this.engine?.pause();
  }

  // 恢复朗读
  resume(): void {
    this.engine?.resume();
  }

  // 停止朗读
  stop(): void {
    this.engine?.stop();
  }

  // 释放资源
  release(): void {
    this.engine?.release();
    this.isInitialized = false;
  }

  private delay(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

5.2 在主页面中使用TTS

// index.ets
import { TtsService } from '../service/TtsService';

@Entry
@Component
struct Index {
  private ttsService: TtsService = new TtsService();
  @State isPlaying: boolean = false;

  async startTts(): Promise<void> {
    this.isPlaying = true;

    try {
      await this.ttsService.initialize();

      const poemList = this.filteredPoems.map(poem => ({
        title: poem.title,
        content: poem.content
      }));

      await this.ttsService.speakPoems(poemList);
    } catch (error) {
      console.error('朗读出错:', error);
    } finally {
      this.isPlaying = false;
    }
  }

  stopTts(): void {
    this.ttsService.stop();
    this.isPlaying = false;
  }

  // 组件销毁时释放TTS资源
  aboutToDisappear(): void {
    this.ttsService.release();
  }
}

5.3 TTS在诗词场景的特殊优化

诗词与普通的文字内容有显著区别:格律、平仄、押韵构成了诗词独特的声韵美。这意味着TTS朗读不能简单地"照本宣科",需要针对诗词场景做专门的优化。

(一)语速调校

普通新闻朗读的推荐语速为每分钟200-250字,而诗词朗读建议控制在每分钟120-150字。这是因为诗词的每个字都有特定的音律功能:平声悠长、仄声短促、入声顿挫。过快的语速会让平仄关系模糊,听众只能接收到字面意思,而无法品味声韵之美。我们在 setSpeechParams 中将 speed 设置为75(即默认值的75%),明显慢于常规语速。

(二)停顿策略

诗词的停顿有其内在规律:五言诗通常在第二字后停顿(2-3节奏),七言诗在第四字后停顿(4-3节奏)。目前的TTS引擎基于语义分析自动处理停顿,虽然无法精确遵循格律,但在绝大多数情况下已经能给出符合语意的停顿位置。对于长诗(如白居易《琵琶行》的616字),我们在每句之间增加300ms的额外停顿,避免听众产生听觉疲劳。

(三)标题与作者朗读

在朗读诗词正文前,TTS会先读出标题和作者(“将进酒,唐·李白”),帮助听众在进入正文前建立心理预期。这看似是微小的细节,但对于在通勤途中闭眼听诗的用户来说,这个上下文提示至关重要。

(四)错误恢复与降级策略

TTS引擎可能因系统资源不足、音频焦点冲突、权限问题等原因朗读失败。我们设计了三级降级策略:

  1. 一级降级:如果初始化失败(如缺少语音包),弹出Toast提示"语音包未安装,请前往设置下载",并提供手动安装的引导
  2. 二级降级:如果朗读过程中中断(如来电打断),自动记录中断位置,用户点击恢复时从中断处继续
  3. 三级降级:如果引擎完全不可用,改为"无声模式"——用户目视阅读,功能不受影响

(五)多角色朗读探索

对于《将进酒》这类带有强烈抒情色彩的诗作,不同段落的情感基调差异很大。"人生得意须尽欢"的洒脱与"与尔同销万古愁"的悲壮,如果用同样的音色和语调朗读,会大大削减艺术感染力。鸿蒙TTS支持通过 setParams 动态调整音调和语速,可以在不同段落中切换朗读风格。虽然当前实现中我们暂未加入多角色朗读(这会增加代码复杂度),但这是一个非常有价值的扩展方向。

  1. 预初始化:在页面 aboutToAppear 生命周期中初始化TTS引擎,避免用户点击朗读后等待
  2. 分段朗读:长诗词按段落分段,每段之间增加间隔,避免朗读卡顿
  3. 速率适配:诗词朗读建议使用稍慢的语速(70-85%),让听众能品味韵律美
  4. 错误处理:如果TTS引擎初始化失败,降级为文字展示并提示用户

六、诗词详情页面

6.1 页面跳转与数据传递

从主页面点击列表项时,使用 router.pushUrl 跳转到详情页,通过 router.Params 传递诗词数据:

// 主页面中的跳转逻辑
openPoemDetail(poem: Poem): void {
  router.pushUrl({
    url: 'pages/PoemDetailPage',
    params: {
      poemId: poem.id,
      poemTitle: poem.title
    }
  });
}

6.2 详情页布局

详情页采用Tab导航,支持原文、注释、翻译、赏析四个面板,并集成单首TTS朗读:

// pages/PoemDetailPage.ets
@Entry
@Component
struct PoemDetailPage {
  @State poem: Poem | null = null;
  @State currentTabIndex: number = 0;
  private ttsService: TtsService = new TtsService();

  aboutToAppear(): void {
    const params = router.getParams() as Record<string, Object>;
    const poemId = params['poemId'] as number;
    // 从本地数据集中加载对应诗词的完整数据
    this.poem = DataLoader.getPoemById(poemId);
  }

  build() {
    Column() {
      // 顶部标题栏
      Row() {
        Image($rawfile('icon_back.png'))
          .width(24)
          .height(24)
          .onClick(() => router.back())

        Text(this.poem?.title || '')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)

        // 朗读按钮
        Button() {
          Image($rawfile('icon_tts.png'))
            .width(24)
            .height(24)
        }
        .width(40)
        .height(40)
        .backgroundColor('#FFF8E7')
        .borderRadius(20)
        .onClick(() => this.readAloud())
      }
      .width('100%')
      .padding(12)

      // 诗词信息行
      Row() {
        Text(this.poem?.poetName || '')
        Text('·')
        Text(this.poem?.genre || '')
        Text('·')
        Text(this.poem?.dynasty || '')
      }
      .fontSize(13)
      .fontColor('#8B7355')
      .margin({ bottom: 8 })

      // 创作背景
      Text(this.poem?.background || '')
        .fontSize(12)
        .fontColor('#999')
        .fontStyle(FontStyle.Italic)
        .padding({ left: 16, right: 16 })
        .margin({ bottom: 12 })

      // Tab导航
      TabBar({
        items: ['原文', '注释', '翻译', '赏析'],
        selectedIndex: this.currentTabIndex,
        onChange: (idx) => this.currentTabIndex = idx
      })

      // 内容区域(根据Tab切换展示不同内容)
      Column() {
        if (this.currentTabIndex === 0) {
          // 原文展示 — 这是TTS朗读的核心内容
          Text(this.poem?.content || '')
            .fontSize(16)
            .fontColor('#333')
            .lineHeight(28)
            .padding(16)
        } else if (this.currentTabIndex === 1) {
          // 注释列表
          List() {
            ForEach(this.poem?.notes || [] , (note: Note) => {
              ListItem() {
                Row() {
                  Text(note.keyword)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#8B4513')
                  Text(note.explanation)
                    .fontSize(13)
                    .fontColor('#666')
                    .layoutWeight(1)
                }
                .padding(12)
              }
            })
          }
        } else if (this.currentTabIndex === 2) {
          Scroll() {
            Text(this.poem?.translation || '')
              .fontSize(15)
              .fontColor('#444')
              .lineHeight(24)
              .padding(16)
          }
        } else {
          Scroll() {
            Text(this.poem?.appreciation || '')
              .fontSize(15)
              .fontColor('#444')
              .lineHeight(24)
              .padding(16)
          }
        }
      }
      .layoutWeight(1)
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
  }

  async readAloud(): Promise<void> {
    await this.ttsService.initialize();
    if (this.poem) {
      await this.ttsService.speakPoem(this.poem.title, this.poem.content);
    }
  }
}

七、跨页面状态管理

7.1 状态共享方案对比

在HarmonyOS应用中,当存在多个页面(如主页面+详情页+搜索页)时,页面间的数据共享是必须解决的问题。ArkTS提供了以下几种方案:

方案 适用场景 优点 缺点
router Params 页面间跳转传参 简单直接 不适合大数据量
AppStorage 全局应用级状态 跨页面响应式 需注意生命周期
LocalStorage 页面级共享状态 作用域可控 配置稍复杂
PersistentStorage 持久化存储 应用重启保留 有异步延迟
单例数据服务 复杂业务数据 灵活可控 需手动管理刷新

在本应用中,诗词的收藏状态(isFavorite)需要在主页面和详情页之间保持一致——用户在详情页收藏一首诗后,回到列表应该看到收藏图标更新。我们采用 AppStorage + 单例数据服务 的组合方案:

// service/DataService.ets — 单例数据服务
export class DataService {
  private static instance: DataService;
  // 使用 AppStorage 存储收藏状态
  @StorageLink('favoriteMap') favoriteMap: Record<number, boolean> = {};

  static getInstance(): DataService {
    if (!DataService.instance) {
      DataService.instance = new DataService();
    }
    return DataService.instance;
  }

  toggleFavorite(poemId: number): void {
    this.favoriteMap[poemId] = !(this.favoriteMap[poemId] || false);
    // AppStorage 会自动触发 UI 刷新
  }

  isFavorite(poemId: number): boolean {
    return this.favoriteMap[poemId] || false;
  }
}

7.2 页面返回时刷新列表

当用户从详情页返回到主页面时,收藏状态可能已变化。我们需要确保列表中的收藏图标正确反映最新状态。有两种实现思路:

思路一(推荐):使用 AppStorage 响应式绑定
主页面中的诗词列表组件直接读取 AppStorage 中的收藏状态,当详情页修改了 favoriteMap,主页面自动刷新。这是最优雅的方案,完全不需要手动触发刷新。

思路二:回调刷新
router.pushUrl 之后,通过 router.on('pop', callback) 监听页面返回事件:

aboutToAppear(): void {
  router.on('pop', () => {
    // 重新读取数据
    this.poems = DataLoader.getAllPoems();
  });
}

这个方案虽然多了一次读取操作,但在数据量不大(44首诗词)的情况下性能影响可忽略不计,且逻辑更直观,适合初学者理解。

7.3 搜索功能的状态管理

搜索功能涉及输入文本和搜索结果两个状态。我们使用 @State 管理搜索文本,并在其变化时实时过滤:

@State searchText: string = '';
@State poets: Poet[] = [];
@State poems: Poem[] = [];

// 搜索诗人的计算逻辑
get searchedPoets(): Poet[] {
  if (!this.searchText.trim()) {
    return this.poets;
  }
  const keyword = this.searchText.trim().toLowerCase();
  return this.poets.filter(p =>
    p.name.includes(keyword) ||
    p.dynasty.includes(keyword)
  );
}

// 搜索诗词的计算逻辑
get searchedPoems(): Poem[] {
  if (!this.searchText.trim()) {
    return this.filteredPoems;
  }
  const keyword = this.searchText.trim().toLowerCase();
  return this.filteredPoems.filter(p =>
    p.title.includes(keyword) ||
    p.content.includes(keyword) ||
    p.background.includes(keyword)
  );
}

这种设计遵循了"状态最小化"原则:searchText 是唯一的真实状态源,searchedPoetssearchedPoems 都是从它派生出来的,无需额外状态变量。


八、主题样式与视觉设计

以下是集成上述所有功能后的完整 index.ets

// pages/index.ets
import router from '@ohos.router';
import { Poet, PoetModel } from '../model/PoetModel';
import { Poem, DataLoader } from '../model/PoemModel';
import { TtsService } from '../service/TtsService';

@Entry
@Component
struct Index {
  // 状态变量
  @State selectedPoetId: number = 1;
  @State poets: Poet[] = PoetModel.getAllPoets();
  @State poems: Poem[] = DataLoader.getAllPoems();
  @State searchText: string = '';
  @State isPlaying: boolean = false;

  // TTS服务实例
  private ttsService: TtsService = new TtsService();

  // 计算当前选中的诗人对象
  get selectedPoet(): Poet | undefined {
    return this.poets.find(p => p.id === this.selectedPoetId);
  }

  // 计算过滤后的诗词列表
  get filteredPoems(): Poem[] {
    return this.poems.filter(p => p.poetId === this.selectedPoetId);
  }

  // 页面生命周期
  aboutToAppear(): void {
    // 预加载数据
    console.info('诗词集应用启动,加载数据...');
  }

  aboutToDisappear(): void {
    this.ttsService.release();
  }

  // 选择诗人
  selectPoet(poetId: number): void {
    if (this.selectedPoetId === poetId) return;
    this.selectedPoetId = poetId;
  }

  // 打开诗词详情
  openPoemDetail(poem: Poem): void {
    router.pushUrl({
      url: 'pages/PoemDetailPage',
      params: { poemId: poem.id, poemTitle: poem.title }
    });
  }

  // 切换收藏
  toggleFavorite(poemId: number): void {
    const poem = this.poems.find(p => p.id === poemId);
    if (poem) {
      poem.isFavorite = !poem.isFavorite;
      // 触发UI刷新
      this.poems = [...this.poems];
    }
  }

  // TTS朗读
  async startTts(): Promise<void> {
    this.isPlaying = true;
    try {
      await this.ttsService.initialize();
      await this.ttsService.setSpeechParams();
      const list = this.filteredPoems.map(p => ({
        title: p.title,
        content: p.content
      }));
      await this.ttsService.speakPoems(list);
    } catch (e) {
      console.error('朗读失败:', e);
    } finally {
      this.isPlaying = false;
    }
  }

  stopTts(): void {
    this.ttsService.stop();
    this.isPlaying = false;
  }

  // ===== 构建UI =====
  build() {
    Column() {
      // ① 顶部搜索栏
      Row() {
        TextInput({ placeholder: '搜索诗人或诗词...', text: this.searchText })
          .width('100%')
          .height(40)
          .backgroundColor('#F5F5F5')
          .borderRadius(20)
          .padding({ left: 16 })
          .onChange((val: string) => this.searchText = val)
      }
      .padding(12)
      .width('100%')
      .backgroundColor('#FFFFFF')

      // ② 诗人网格区
      Grid() {
        ForEach(this.poets, (poet: Poet) => {
          GridItem() {
            Column() {
              // 头像
              Image($rawfile(poet.avatar))
                .width(56)
                .height(56)
                .borderRadius(28)
                .objectFit(ImageFit.Cover)

              Text(poet.name)
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .margin({ top: 6 })

              Text(poet.dynasty)
                .fontSize(11)
                .fontColor('#8B7355')

              Text(`${poet.poemCount}`)
                .fontSize(10)
                .fontColor('#ccc')
                .margin({ top: 2 })

              Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
                ForEach(poet.styleTags, (tag: string) => {
                  Text(tag)
                    .fontSize(9)
                    .fontColor('#8B4513')
                    .backgroundColor('#FFF0D0')
                    .borderRadius(6)
                    .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                    .margin(2)
                })
              }
            }
            .width('100%')
            .padding(12)
            .backgroundColor(
              this.selectedPoetId === poet.id ? '#FFF5E6' : '#FAFAFA'
            )
            .borderRadius(12)
            .shadow({
              radius: this.selectedPoetId === poet.id ? 8 : 4,
              color: '#20000000',
              offsetX: 0,
              offsetY: 2
            })
            .onClick(() => this.selectPoet(poet.id))
          }
        })
      }
      .columnsTemplate('1fr 1fr')
      .rowsTemplate('1fr 1fr 1fr 1fr')
      .columnsGap(12)
      .rowsGap(12)
      .padding(12)
      .height(380)

      // ③ 分隔控制栏
      Row() {
        Row() {
          Text(this.selectedPoet?.name || '')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#8B4513')

          Text(`  ·  ${this.filteredPoems.length}`)
            .fontSize(13)
            .fontColor('#666')

          Blank()

          Button(this.isPlaying ? '⏹ 停止' : '▶ 朗读全部')
            .fontSize(12)
            .fontColor('#FFFFFF')
            .backgroundColor(this.isPlaying ? '#D32F2F' : '#8B4513')
            .borderRadius(16)
            .height(32)
            .onClick(() => {
              this.isPlaying ? this.stopTts() : this.startTts();
            })
        }
        .padding({ left: 16, right: 16 })
      }
      .width('100%')
      .height(48)
      .backgroundColor('#FFF8E7')

      // ④ 诗词列表区
      List({ space: 8 }) {
        ForEach(this.filteredPoems, (poem: Poem, index: number) => {
          ListItem() {
            Row() {
              // 序号
              Text(`${index + 1}`)
                .fontSize(14)
                .fontColor('#8B4513')
                .fontWeight(FontWeight.Bold)
                .width(28)
                .textAlign(TextAlign.Center)

              Column() {
                Row() {
                  Text(poem.title)
                    .fontSize(16)
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                    .layoutWeight(1)

                  Text(poem.genre)
                    .fontSize(10)
                    .fontColor('#8B4513')
                    .backgroundColor('#FFF0D0')
                    .borderRadius(6)
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                }

                Text(poem.background)
                  .fontSize(11)
                  .fontColor('#999')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                  .margin({ top: 2 })

                Text(poem.content.length > 26 ?
                  poem.content.substring(0, 24) + '···' :
                  poem.content)
                  .fontSize(12)
                  .fontColor('#666')
                  .italic(true)
                  .margin({ top: 2 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)

              // 收藏
              Image($rawfile(poem.isFavorite ? 'star_filled.png' : 'star_outline.png'))
                .width(20)
                .height(20)
                .onClick(() => this.toggleFavorite(poem.id))
            }
            .width('100%')
            .padding(12)
            .backgroundColor('#FAFAFA')
            .borderRadius(10)
            .onClick(() => this.openPoemDetail(poem))
          }
        })
      }
      .width('100%')
      .layoutWeight(1)
      .padding({ left: 12, right: 12, bottom: 12 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F8F6F2')
  }
}

八、主题样式与视觉设计

8.1 配色方案

诗词鉴赏应用采用"素雅古风"配色方案,体现传统文化韵味:

用途 色值(HEX) 色值(RGBA) 说明
主色 #8B4513 rgb(139,69,19) 赭石色,古书墨韵
背景 #F8F6F2 rgb(248,246,242) 宣纸色,柔和护眼
卡片 #FAFAFA rgba(250,250,250) 近白色,干净通透
选中 #FFF5E6 rgba(255,245,230) 浅杏色,温和高亮
标签底 #FFF0D0 rgba(255,240,208) 淡金色,标签背景
正文 #333333 rgba(51,51,51) 深灰色,阅读舒适
辅助 #999999 rgba(153,153,153) 灰色,次要信息
强调 #D32F2F rgba(211,47,47) 红色,停止/警告

8.2 字体与排版

  1. 标题:使用 fontWeight: FontWeight.Bold,字号 16-20vp,行高 1.6
  2. 正文(诗词原文):字号 16vp,行高 28vp(约1.75倍),字间距 1vp,营造古书竖排的呼吸感
  3. 辅助文字:字号 11-13vp,颜色 #999/#666,用于背景、体裁标签等次要信息
  4. 圆角规范:卡片 12vp,小标签 6vp,按钮 16vp(药丸形)

8.3 图片资源

用户需准备以下资源文件,放置在 entry/src/main/resources/rawfile/ 目录下:

rawfile/
├── images/poets/
│   ├── libai.png
│   ├── dufu.png
│   ├── sushi.png
│   ├── liqingzhao.png
│   ├── baijuyi.png
│   ├── wangwei.png
│   ├── lishangyin.png
│   └── xinqiji.png
├── icon_back.png        // 返回箭头
├── icon_tts.png         // TTS朗读
├── star_filled.png      // 已收藏(实心星)
└── star_outline.png     // 未收藏(空心星)

九、性能优化与最佳实践

9.1 List的性能优化

对于诗词列表(最多6-8项),性能压力不大,但作为示范需要遵循以下原则:

  1. .lazy 属性:当数据量超过50条时,为List启用懒加载模式
  2. ListItem复用:ArkTS Runtime会自动复用ListItem,但要注意避免在 ForEach 中创建复杂的匿名函数
  3. 固定高度:如果列表项高度一致,使用 .estimatedHeight(80) 提前告知布局引擎

9.2 Grid的性能优化

  1. .cachedCount:设置 cachedCount(2) 预渲染上下各2行,减少滚动时的白屏
  2. 减少GridItem内部嵌套层级:单层Column够用就不要用多层Flex嵌套
  3. 图片资源:诗人头像控制在 60×60px,使用 objectFit(ImageFit.Cover) 而非 ImageFit.Fill 避免形变

9.3 TTS性能考虑

  1. 引擎复用:整个应用生命周期内只创建一次TTS Engine,不要每次朗读都重新初始化
  2. 异步非阻塞speak() 方法是异步的,使用 await 确保顺序执行但不要阻塞UI线程
  3. 内存释放:在页面 aboutToDisappear 中务必调用 release() 释放音频资源

9.4 状态管理原则

  1. 最小化 @State:只将用户交互直接改变的变量声明为 @State,派生数据用 get 方法
  2. 深拷贝触发刷新:修改对象属性后,使用展开运算符重新赋值(this.poems = [...this.poems])强制刷新
  3. 避免不必要的ForEach key变更:为每个列表项提供稳定的 key(如 poem.id.toString()

十、测试与验证

10.1 功能测试用例

测试项 操作步骤 预期结果
诗人切换 点击不同诗人卡片 下方列表切换为该诗人的作品
诗词详情 点击列表项 跳转到详情页,展示原文
TTS朗读 点击"朗读全部" 按顺序朗读当前诗人的所有作品
TTS停止 点击"停止" 立即停止朗读
收藏切换 点击收藏图标 图标在实心和空心之间切换
Tab切换 在详情页点击不同Tab 显示对应的注释/翻译/赏析内容

10.2 兼容性验证

设备类型 分辨率 布局预期
Phone(6.1寸) 360×780 2列Grid正常显示
Phone(6.7寸) 393×852 2列Grid居中均匀分布
Tablet(10寸) 1280×800 2列Grid可以保留,或改为3列
Foldable(展开) 900×1200 Grid自动适配空间

十一、总结与扩展方向

11.1 本文要点回顾

通过构建"鸿蒙ArkTS诗词集"应用,我们完整实现了以下关键技术:

  1. Grid布局:通过 columnsTemplate 实现2列自适应网格,展示诗人卡片
  2. List布局:通过 List + ListItem 实现纵向滚动列表,展示诗词条目
  3. Grid-List联动:通过 @State 和 computed getter 实现选中诗人后列表实时过滤
  4. TTS语音播报:调用 @ohos.multimedia.textToSpeech 实现诗词的语音朗读
  5. 路由导航:使用 @ohos.router 实现主页面到详情页的跳转与参数传递

11.2 可扩展的功能方向

  1. 全文搜索:利用 Search 组件集成诗词标题和诗句的模糊搜索
  2. 随机一诗:增加"每日一诗"功能,每天推荐一首随机诗词
  3. 多语言翻译:支持英文/日文翻译,推动中华文化出海
  4. 水墨动画:结合Canvas API实现水墨风格的动画背景
  5. 离线词库:扩展至《全唐诗》四万余首,使用SQLite本地存储
  6. 社区功能:用户可发表对诗词的感悟点评,形成诗词爱好者社区
  7. AI辅助赏析:集成AI大模型,为用户提供个性化的诗词解读

11.3 对ArkTS开发者的建议

对于正在学习ArkTS的开发者,本文演示的List+Grid双布局模式是一个非常实用的"万能模板":

  • 电商应用:Grid展示商品分类 → List展示商品详情
  • 社交应用:Grid展示用户分类 → List展示动态内容
  • 音乐应用:Grid展示专辑封面 → List展示歌曲列表
  • 学习应用:Grid展示科目分类 → List展示课程章节

这种"概览-详情"的双层信息架构,几乎适用于所有内容型应用,建议开发者将其作为ArkTS开发的"基础设计模式"之一熟练掌握。


十二、ArkTS开发哲学:声明式UI的思考

12.1 从命令式到声明式的思维转变

对于从Java或传统Android开发转过来的开发者而言,ArkTS最大的挑战不在语法层面,而在思维方式本身。在传统的命令式UI开发中,开发者需要亲自维护UI的每一个状态变化:button.setText("已收藏")listView.notifyDataSetChanged()gridView.setVisibility(View.GONE)——每一步变化都需要明确的指令。而在ArkTS的声明式体系中,开发者不再描述"如何变化",而是描述"应该是什么样子",框架自动计算变化路径。

这种思维的转变可以用一个比喻来理解:命令式编程像一名详细的导演,事无巨细地告诉每个演员每一步应该怎么走;声明式编程像一名建筑设计师,画出最终的设计图,由施工团队(ArkTS框架)去决定最佳的实现路径。对于诗词集应用而言,这意味着:

// 命令式思维(伪代码)
user clicks poet card → 
  find poet by id → 
  set selectedPoetId variable → 
  clear list → 
  query poems by poet id → 
  rebuild list items → 
  update TTS bar text → 
  scroll list to top

// 声明式思维(ArkTS实际代码)
@State selectedPoetId: number = 1;
// 框架自动完成:诗人类别高亮变化、列表过滤、控制栏文本更新

开发者只需要声明 selectedPoetId 是一个状态变量,然后在模板中描述"诗人卡片在 selectedPoetId === poet.id 时显示高亮"、"列表只展示 poetId === selectedPoetId 的诗词"即可。所有的派生变化由ArkTS的响应式系统自动处理。

12.2 分层架构的必然性

在编写ArkTS应用时,很多初学者容易犯的一个错误是将所有逻辑塞入页面文件(.ets),导致单个文件达到上千行。我们的诗词集应用虽然以 index.ets 为演示核心,但在真实工程中必须遵循分层架构:

层级 职责 文件示例 与UI关系
页面层 布局与交互 index.ets 直接渲染UI
数据层 数据加载与缓存 PoemModel.ets 页面读取数据
服务层 业务逻辑与系统API TtsService.ets 页面调用服务
公共层 常量与工具函数 constants.ets 各层引用

这种分层带来了三个核心好处:

  1. 可测试性:数据模型可以脱离UI单独进行单元测试。例如,测试 PoemModel.getAllPoems() 是否返回了正确数量的诗词,不需要启动模拟器
  2. 可复用性TtsService 既可以在主页面用于批量朗读,也可以在详情页用于单首朗读,只需一处实现两处调用
  3. 可维护性:当华为更新TTS的API时,只需修改 TtsService.ets 一个文件,所有调用方无需改动

12.3 性能意识:响应式系统的隐性契约

ArkTS的响应式系统虽然强大,但它建立在一个隐性契约之上:开发者需要理解"什么变化会触发刷新,什么不会"。不遵循这个契约会导致两种典型的性能问题:

问题一:不必要的全量刷新

// ❌ 错误做法:每次选诗人时重新创建整个诗词数组
selectPoetBad(id: number) {
  this.selectedPoetId = id;
  this.displayedPoems = this.allPoems.filter(p => p.poetId === id);
  // 这会触发 List 的完全重建
}

// ✅ 正确做法:使用 computed getter 而非独立状态
get displayedPoems(): Poem[] {
  return this.allPoems.filter(p => p.poetId === this.selectedPoetId);
  // 只刷新 affected UI 节点
}

问题二:对象引用不变导致的刷新失效

// ❌ 错误做法:直接修改对象属性,引用不变
toggleFavorite(id: number) {
  const poem = this.poems.find(p => p.id === id);
  if (poem) poem.isFavorite = !poem.isFavorite;
  // UI 不会刷新!因为 poems 数组的引用没有变化
}

// ✅ 正确做法:创建新数组触发引用变化
toggleFavorite(id: number) {
  this.poems = this.poems.map(p => {
    if (p.id === id) return { ...p, isFavorite: !p.isFavorite };
    return p;
  });
}

这个隐形契约的根源在于JavaScript/TypeScript的对象引用机制。ArkTS使用引用比较来判断状态是否变化,直接修改对象的属性不会改变引用地址,因此UI无法感知变化。使用展开运算符(...)创建新对象是一个简单而有效的解决方案。

12.4 文化自信与技术自信

写到这里,我们不妨将视野拉远一些。HarmonyOS的诞生背景是全球技术格局的深刻变革,而ArkTS作为鸿蒙生态的核心开发语言,承载着中国基础软件自主创新的使命。当我们用ArkTS开发一款诗词鉴赏应用时,这不仅仅是技术实现,更是中国开发者在自主技术栈上传承中华优秀传统文化的具体实践。

从某种意义上说,Grid网格中的每一位诗人——李白、杜甫、苏轼、李清照——他们都是千年前那个时代的"创新者"。李白打破了格律的束缚创造了全新的诗歌境界,苏轼在词的领域拓展了前所未有的表现空间。今天的鸿蒙开发者,同样在创造一个新的数字世界——一个基于中国自主操作系统、面向万物互联时代的新世界。这种跨越千年的精神呼应,正是"诗词+代码"这个组合最动人的地方。

12.5 给学习者的最后建议

如果你是一位正在学习ArkTS的开发者,以下三点建议或许能帮你更快地成长:

  1. 从模仿开始,但不要止于模仿:跟随本文的代码实现一遍诗词集应用后,尝试将其改造为另一个主题——比如"书法鉴赏"“民乐百科”“戏曲文化”——保持List+Grid的架构不变,替换数据源和视觉风格。只有通过改造,你才能真正理解架构的边界在哪里。

  2. 养成阅读官方文档的习惯:华为开发者文档的质量在持续提升,API参考、开发指南、Sample代码都在不断完善。遇到问题时,优先查官方文档而非搜索引擎——你很可能在官方文档中找到最佳实践。

  3. 关注 HarmonyOS 生态的演进:ArkTS在快速迭代中,每年有两次大版本更新(秋季和春季),每次都会引入大量新特性和性能优化。关注华为开发者大会(HDC)、订阅官方技术博客、加入鸿蒙开发者社区,这些投入的回报会远超你的预期。

最后,送给大家一句苏轼的话作为本文的结尾:"古之立大事者,不惟有超世之才,亦必有坚忍不拔之志。"学习一门新的开发语言、构建一个完整的鸿蒙应用,确实需要坚忍不拔之志。但当你的第一个ArkTS应用在真机上流畅运行时,那份成就感是无法用语言形容的。


附录:完整参考文献

  1. 华为开发者文档 - TextToSpeech开发指导
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/texttospeech-guide-V5

  2. 华为开发者文档 - ArkTS List组件
    https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V5/ts-container-list-V5

  3. 华为开发者文档 - ArkTS Grid组件
    https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V5/ts-container-grid-V5

  4. 华为开发者文档 - 状态管理
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/arkts-state-management-V5

  5. 《唐诗三百首》中华书局

  6. 《宋词三百首》中华书局


Logo

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

更多推荐