HarmonyOS NEXT 实战:从零打造多语言翻译应用

网络请求 + 数据持久化 + 语言自动检测,手把手实现一个完整的翻译工具

前言

在学习 HarmonyOS NEXT 开发的过程中,我选择了一个实用的练手项目——多语言翻译应用。这个项目虽然功能看似简单,但却涵盖了移动应用开发的多个核心知识点:

  • ✅ 网络请求:调用翻译 API
  • ✅ 数据持久化:保存翻译历史
  • ✅ 列表管理:历史记录、收藏功能
  • ✅ 状态管理:响应式 UI 更新
  • ✅ 工具类封装:剪贴板、时间格式化

本文将详细记录这个翻译应用从设计到实现的完整过程,希望能为学习 HarmonyOS NEXT 开发的朋友提供参考。

SDK:HarmonyOS NEXT API 23
开发工具:DevEco Studio
项目类型:Empty Ability


一、项目设计

1.1 功能需求

在开始编码之前,我先梳理了应用的核心功能:

核心功能

  1. 文本翻译:输入文本,点击翻译,显示结果
  2. 语言切换:选择源语言和目标语言
  3. 语言互换:一键切换源语言和目标语言
  4. 自动检测:根据输入内容自动识别语言

辅助功能

  1. 历史记录:保存所有翻译记录,支持搜索
  2. 收藏功能:标记重要翻译,快速访问
  3. 复制结果:一键复制翻译结果
  4. 清除输入:快速清空输入框

用户体验

  1. 加载状态:翻译时显示加载动画
  2. 错误提示:网络异常时友好提示
  3. 空状态:无历史/收藏时的提示

1.2 界面设计

应用采用三标签页布局:

┌─────────────────────────────────┐
│    翻译  │  历史  │  收藏       │  ← 顶部标签栏
├─────────────────────────────────┤
│  [中文 ▼]  ⇄  [英语 ▼]         │  ← 语言选择栏
├─────────────────────────────────┤
│                                 │
│  ┌─────────────────────────┐   │
│  │  请输入要翻译的文本...    │   │  ← 输入区域
│  │                          │   │
│  └─────────────────────────┘   │
│  字符数: 0/2000        [清除]  │
│                                 │
│      [ 翻 译 ]                 │  ← 翻译按钮
│                                 │
│  ┌─────────────────────────┐   │
│  │  翻译结果                 │   │  ← 结果区域
│  │                          │   │
│  └─────────────────────────┘   │
│  [复制] [朗读]                 │
│                                 │
└─────────────────────────────────┘

1.3 架构设计

采用分层架构设计:

┌─────────────────────────────────────────┐
│          Presentation Layer             │
│  ┌─────────────────────────────────┐   │
│  │  pages/Index.ets                │   │  ← 页面组件
│  │  ├─ 翻译页                       │   │
│  │  ├─ 历史页                       │   │
│  │  └─ 收藏页                       │   │
│  └─────────────────────────────────┘   │
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│          Business Layer                │
│  ┌─────────────────────────────────┐   │
│  │  service/TranslationService.ets │   │  ← 翻译服务
│  │  data/PreferencesManager.ets    │   │  ← 数据管理
│  └─────────────────────────────────┘   │
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│          Data Layer                    │
│  ┌─────────────────────────────────┐   │
│  │  model/TranslationEntry.ets     │   │  ← 数据模型
│  │  utils/SystemUtils.ets         │   │  ← 工具类
│  └─────────────────────────────────┘   │
└─────────────────────────────────────────┘

1.4 项目结构

MyApplication/
├── AppScope/
│   └── app.json5                     # 应用全局配置
├── entry/
│   └── src/main/
│       ├── ets/
│       │   ├── entryability/
│       │   │   └── EntryAbility.ets  # 应用入口
│       │   ├── model/
│       │   │   └── TranslationEntry.ets  # 数据模型
│       │   ├── service/
│       │   │   └── TranslationService.ets  # 翻译服务
│       │   ├── data/
│       │   │   └── PreferencesManager.ets  # 数据持久化
│       │   ├── utils/
│       │   │   └── SystemUtils.ets    # 工具类
│       │   └── pages/
│       │       └── Index.ets          # 主页面
│       ├── resources/
│       │   └── base/
│       │       ├── element/           # 字符串、颜色资源
│       │       ├── media/             # 图片资源
│       │       └── profile/
│       │           └── main_pages.json # 页面路由配置
│       └── module.json5               # 模块配置
└── build-profile.json5               # 构建配置

二、数据模型设计

2.1 翻译记录模型

model/TranslationEntry.ets 中定义数据结构:

/**
 * 翻译记录数据模型
 */
export interface TranslationEntry {
  /** 唯一标识 */
  id: string;
  /** 源语言代码(如 zh, en) */
  sourceLang: string;
  /** 目标语言代码 */
  targetLang: string;
  /** 源文本 */
  sourceText: string;
  /** 翻译结果 */
  targetText: string;
  /** 创建时间戳(毫秒) */
  timestamp: number;
  /** 是否收藏 */
  isFavorite: boolean;
}

2.2 语言选项模型

/**
 * 语言选项
 */
export interface LanguageOption {
  /** 语言代码(如 zh, en, ja) */
  code: string;
  /** 显示名称 */
  name: string;
}

/**
 * 预定义语言列表
 */
export const LANGUAGES: LanguageOption[] = [
  { code: 'zh', name: '中文' },
  { code: 'en', name: '英语' },
  { code: 'ja', name: '日语' },
  { code: 'ko', name: '韩语' },
  { code: 'fr', name: '法语' },
  { code: 'de', name: '德语' },
  { code: 'es', name: '西班牙语' },
  { code: 'ru', name: '俄语' },
  { code: 'pt', name: '葡萄牙语' },
  { code: 'it', name: '意大利语' },
  { code: 'th', name: '泰语' },
  { code: 'vi', name: '越南语' },
];

支持 12 种语言,涵盖主流语种。

2.3 辅助函数

/**
 * 语言代码转显示名
 */
export function getLanguageName(code: string): string {
  const lang = LANGUAGES.find(l => l.code === code);
  return lang?.name ?? code;
}

/**
 * 生成唯一ID
 */
export function generateId(): string {
  return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
}

三、翻译服务实现

3.1 API 选择

我选择了两个免费的翻译 API:

  1. LibreTranslate(主)

    • 开源翻译服务
    • 免费使用,无需 API Key
    • 支持多语言
  2. MyMemory(备用)

    • 免费翻译 API
    • 有每日调用限制(10000 字符)
    • 作为 LibreTranslate 的备用方案

3.2 翻译服务类

service/TranslationService.ets 中实现:

import { http } from '@kit.NetworkKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';

const DOMAIN = 0x0000;
const TAG = 'TranslationService';

/**
 * 翻译服务
 * 使用 LibreTranslate 开源翻译API(免费,无需API Key)
 * 备用:使用 MyMemory API
 */
export class TranslationService {
  private static instance: TranslationService;

  // LibreTranslate public instance
  private static readonly LIBRE_TRANSLATE_URL = 'https://libretranslate.com/translate';
  // MyMemory API as fallback
  private static readonly MY_MEMORY_URL = 'https://api.mymemory.translated.net/get';

  private constructor() {}

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

3.3 主翻译方法

/**
 * 翻译文本
 */
async translate(text: string, sourceLang: string, targetLang: string): Promise<string> {
  if (!text.trim()) return '';

  // Try LibreTranslate first
  try {
    const result = await this.translateLibre(text, sourceLang, targetLang);
    if (result) return result;
  } catch (err) {
    hilog.warn(DOMAIN, TAG, 'LibreTranslate failed, trying MyMemory: ' + JSON.stringify(err));
  }

  // Fallback to MyMemory
  try {
    const result = await this.translateMyMemory(text, sourceLang, targetLang);
    if (result) return result;
  } catch (err) {
    hilog.error(DOMAIN, TAG, 'MyMemory also failed: ' + JSON.stringify(err));
  }

  throw new Error('所有翻译服务均不可用,请检查网络连接');
}

采用双重容错机制:先尝试 LibreTranslate,失败后自动切换到 MyMemory。

3.4 LibreTranslate 实现

/**
 * LibreTranslate API
 */
private async translateLibre(text: string, sourceLang: string, targetLang: string): Promise<string | null> {
  return new Promise<string | null>((resolve, reject) => {
    const httpRequest = http.createHttp();
    const body = JSON.stringify({
      q: text,
      source: sourceLang,
      target: targetLang,
      format: 'text',
    });

    httpRequest.request(
      TranslationService.LIBRE_TRANSLATE_URL,
      {
        method: http.RequestMethod.POST,
        header: {
          'Content-Type': 'application/json',
        },
        extraData: body,
        connectTimeout: 8000,
        readTimeout: 8000,
      },
      (err: BusinessError, data: http.HttpResponse) => {
        httpRequest.destroy();  // 释放资源
        if (err) {
          reject(err);
          return;
        }
        try {
          const json = JSON.parse(data.result as string) as LibreTranslateResponse;
          if (json.translatedText) {
            resolve(json.translatedText);
          } else {
            reject(new Error('无效响应'));
          }
        } catch (e) {
          reject(e);
        }
      }
    );
  });
}

关键点

  1. 创建 HTTP 请求:使用 http.createHttp()
  2. 设置请求参数:方法、头部、超时时间
  3. 处理响应:解析 JSON,提取翻译结果
  4. 释放资源:调用 destroy() 释放 HTTP 请求

3.5 MyMemory 实现

/**
 * MyMemory API (fallback)
 */
private async translateMyMemory(text: string, sourceLang: string, targetLang: string): Promise<string | null> {
  return new Promise<string | null>((resolve, reject) => {
    const httpRequest = http.createHttp();
    const langPair = sourceLang + '|' + targetLang;
    const url = TranslationService.MY_MEMORY_URL + '?q=' + encodeURIComponent(text) + '&langpair=' + langPair;

    httpRequest.request(
      url,
      {
        method: http.RequestMethod.GET,
        connectTimeout: 8000,
        readTimeout: 8000,
      },
      (err: BusinessError, data: http.HttpResponse) => {
        httpRequest.destroy();
        if (err) {
          reject(err);
          return;
        }
        try {
          const json = JSON.parse(data.result as string) as MyMemoryResponse;
          if (json.responseData && json.responseData.translatedText) {
            resolve(json.responseData.translatedText);
          } else {
            reject(new Error('无效响应'));
          }
        } catch (e) {
          reject(e);
        }
      }
    );
  });
}

MyMemory 使用 GET 请求,参数通过 URL 传递。

3.6 语言自动检测

/**
 * 语言自动检测
 */
static detectLanguage(text: string): string {
  if (!text.trim()) return 'en';

  // Check for Chinese characters
  for (let i = 0; i < text.length; i++) {
    const code = text.charCodeAt(i);
    if ((code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3400 && code <= 0x4dbf)) {
      return 'zh';
    }
  }

  // Check for Japanese
  for (let i = 0; i < text.length; i++) {
    const code = text.charCodeAt(i);
    if ((code >= 0x3040 && code <= 0x309f) || (code >= 0x30a0 && code <= 0x30ff)) {
      return 'ja';
    }
  }

  // Check for Korean
  for (let i = 0; i < text.length; i++) {
    const code = text.charCodeAt(i);
    if ((code >= 0xac00 && code <= 0xd7af) || (code >= 0x1100 && code <= 0x11ff)) {
      return 'ko';
    }
  }

  return 'en';
}

通过 Unicode 范围判断语言:

  • 中文:U+4E00-U+9FFF(常用汉字)、U+3400-U+4DBF(扩展汉字)
  • 日文:U+3040-U+309F(平假名)、U+30A0-U+30FF(片假名)
  • 韩文:U+AC00-U+D7AF(韩文音节)、U+1100-U+11FF(韩文字母)

四、数据持久化实现

4.1 Preferences 简介

HarmonyOS 提供了 @ohos.data.preferences 用于轻量级数据存储:

  • 类似 Android 的 SharedPreferences
  • 支持键值对存储
  • 数据存储在本地文件
  • 适合存储少量数据(建议不超过 10KB)

4.2 数据管理器

data/PreferencesManager.ets 中实现:

import { preferences } from '@kit.ArkData';
import { TranslationEntry, generateId } from '../model/TranslationEntry';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN = 0x0000;
const TAG = 'PreferencesManager';

const STORE_NAME = 'translator_preferences';
const KEY_HISTORY = 'translation_history';
const KEY_MAX_HISTORY = 200;

/**
 * 数据持久化管理器
 */
export class PreferencesManager {
  private static instance: PreferencesManager;
  private pref: preferences.Preferences | null = null;

  private constructor() {}

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

  /**
   * 初始化Preferences
   */
  async init(context: Context): Promise<void> {
    try {
      this.pref = await preferences.getPreferences(context, STORE_NAME);
      hilog.info(DOMAIN, TAG, 'Preferences initialized');
    } catch (err) {
      hilog.error(DOMAIN, TAG, 'Failed to init preferences: %{public}s', JSON.stringify(err));
    }
  }
}

4.3 添加翻译记录

/**
 * 添加一条翻译记录
 */
async addEntry(sourceLang: string, targetLang: string,
  sourceText: string, targetText: string): Promise<TranslationEntry> {
  const entries = await this.getAllEntries();
  const newEntry: TranslationEntry = {
    id: generateId(),
    sourceLang,
    targetLang,
    sourceText,
    targetText,
    timestamp: Date.now(),
    isFavorite: false,
  };

  entries.unshift(newEntry);  // 添加到数组开头

  // 限制最大条数
  if (entries.length > KEY_MAX_HISTORY) {
    entries.length = KEY_MAX_HISTORY;
  }

  await this.saveEntries(entries);
  return newEntry;
}

限制历史记录最多 200 条,防止数据量过大。

4.4 获取历史记录

/**
 * 获取所有翻译记录
 */
async getAllEntries(): Promise<TranslationEntry[]> {
  if (!this.pref) return [];
  try {
    const jsonStr: string = (this.pref.get(KEY_HISTORY, '[]') as preferences.ValueType) as string;
    const entries: TranslationEntry[] = JSON.parse(jsonStr) as TranslationEntry[];
    return entries.sort((a, b) => b.timestamp - a.timestamp);  // 按时间倒序
  } catch (err) {
    hilog.error(DOMAIN, TAG, 'Failed to get entries: %{public}s', JSON.stringify(err));
    return [];
  }
}

4.5 收藏功能

/**
 * 切换收藏状态
 */
async toggleFavorite(id: string): Promise<void> {
  const entries = await this.getAllEntries();
  const entry = entries.find(e => e.id === id);
  if (entry) {
    entry.isFavorite = !entry.isFavorite;
    await this.saveEntries(entries);
  }
}

/**
 * 获取收藏列表
 */
async getFavorites(): Promise<TranslationEntry[]> {
  const entries = await this.getAllEntries();
  return entries.filter(e => e.isFavorite);
}

4.6 搜索功能

/**
 * 搜索历史记录
 */
async searchEntries(keyword: string): Promise<TranslationEntry[]> {
  const entries = await this.getAllEntries();
  const kw = keyword.toLowerCase();
  return entries.filter(e =>
    e.sourceText.toLowerCase().includes(kw) ||
    e.targetText.toLowerCase().includes(kw)
  );
}

同时搜索源文本和翻译结果。

4.7 保存数据

/**
 * 保存记录列表到Preferences
 */
private async saveEntries(entries: TranslationEntry[]): Promise<void> {
  if (!this.pref) return;
  try {
    await this.pref.put(KEY_HISTORY, JSON.stringify(entries));
    await this.pref.flush();  // 必须调用 flush 持久化到磁盘
  } catch (err) {
    hilog.error(DOMAIN, TAG, 'Failed to save entries: %{public}s', JSON.stringify(err));
  }
}

关键点:必须调用 flush() 才能将数据持久化到磁盘。


五、工具类实现

5.1 剪贴板工具

utils/SystemUtils.ets 中实现:

import { pasteboard } from '@kit.BasicServicesKit';

/**
 * 剪切板工具类
 */
export class ClipboardUtil {
  /**
   * 复制文本到系统剪切板
   */
  static copyText(context: Context, text: string): void {
    try {
      const data = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
      const pb = pasteboard.getSystemPasteboard();
      pb.setData(data);
      hilog.info(DOMAIN, TAG, 'Text copied to clipboard');
    } catch (err) {
      hilog.error(DOMAIN, TAG, 'Failed to copy: ' + JSON.stringify(err));
    }
  }
}

使用 @kit.BasicServicesKit 的 pasteboard 模块操作系统剪贴板。

5.2 时间格式化

/**
 * 格式化时间戳为友好的显示格式
 */
export function formatTime(timestamp: number): string {
  const date = new Date(timestamp);
  const now = new Date();

  const isToday = date.getDate() === now.getDate() &&
    date.getMonth() === now.getMonth() &&
    date.getFullYear() === now.getFullYear();

  const isYesterday = date.getDate() === now.getDate() - 1 &&
    date.getMonth() === now.getMonth() &&
    date.getFullYear() === now.getFullYear();

  const isThisYear = date.getFullYear() === now.getFullYear();

  const hours = padZero(date.getHours());
  const minutes = padZero(date.getMinutes());
  const timeStr = hours + ':' + minutes;

  if (isToday) {
    return timeStr;  // 今天:只显示时间
  }

  if (isYesterday) {
    return '昨天 ' + timeStr;
  }

  const month = padZero(date.getMonth() + 1);
  const day = padZero(date.getDate());

  if (isThisYear) {
    return month + '-' + day + ' ' + timeStr;  // 今年:月-日 时:分
  }

  return date.getFullYear() + '-' + month + '-' + day;  // 其他:年-月-日
}

function padZero(num: number): string {
  return num < 10 ? '0' + num : num.toString();
}

显示规则:

  • 今天:只显示时间,如 “14:30”
  • 昨天:显示 “昨天 14:30”
  • 今年:显示 “03-15 14:30”
  • 其他:显示 “2025-03-15”

六、页面实现

6.1 主页面结构

pages/Index.ets 中实现主页面:

@Entry
@Component
struct Index {
  @State currentTab: number = 0;
  @State sourceLang: string = 'zh';
  @State targetLang: string = 'en';
  @State sourceText: string = '';
  @State translatedText: string = '';
  @State isTranslating: boolean = false;
  @State showLangPicker: boolean = false;
  @State pickingSource: boolean = true;
  @State showResult: boolean = false;

  private translationService: TranslationService = TranslationService.getInstance();
  private prefManager: PreferencesManager = PreferencesManager.getInstance();

  private tabs: string[] = ['翻译', '历史', '收藏'];

  build() {
    Column() {
      this.buildTopBar();

      if (this.currentTab === 0) {
        this.buildTranslationPage();
      } else if (this.currentTab === 1) {
        HistoryTab({ prefManager: this.prefManager, onSelectEntry: (entry) => {...} });
      } else if (this.currentTab === 2) {
        FavoritesTab({ prefManager: this.prefManager, onSelectEntry: (entry) => {...} });
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.page_bg'))
    .bindContentCover($$this.showLangPicker, this.buildLangPicker())
  }
}

6.2 顶部标签栏

@Builder
buildTopBar() {
  Row() {
    ForEach(this.tabs, (tab: string, index: number) => {
      Column() {
        Text(tab)
          .fontSize(16)
          .fontColor(this.currentTab === index ?
            $r('app.color.tab_active') : $r('app.color.tab_inactive'))
          .fontWeight(this.currentTab === index ? FontWeight.Medium : FontWeight.Regular)
          .padding({ top: 8, bottom: 8 })

        if (this.currentTab === index) {
          Divider()
            .strokeWidth(3)
            .color($r('app.color.tab_active'))
            .width(24)
            .borderRadius(2)
        }
      }
      .layoutWeight(1)
      .onClick(() => {
        this.currentTab = index;
      })
    }, (tab: string) => tab)
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 8 })
  .backgroundColor(Color.White)
}

6.3 语言选择栏

@Builder
buildLanguageBar() {
  Row() {
    // 源语言选择
    Row() {
      Text(getLanguageName(this.sourceLang))
        .fontSize(14)
        .fontColor($r('app.color.text_primary'))
      Image($r('app.media.ic_arrow_down'))
        .width(12)
        .height(12)
        .fillColor($r('app.color.text_secondary'))
    }
    .padding({ left: 12, right: 12, top: 6, bottom: 6 })
    .borderRadius(16)
    .backgroundColor($r('app.color.chip_bg'))
    .onClick(() => {
      this.pickingSource = true;
      this.showLangPicker = true;
    })

    // 语言互换按钮
    Image($r('app.media.ic_swap'))
      .width(20)
      .height(20)
      .fillColor($r('app.color.primary'))
      .margin({ left: 12, right: 12 })
      .onClick(() => {
        const tmpLang = this.sourceLang;
        this.sourceLang = this.targetLang;
        this.targetLang = tmpLang;
        // 同时交换文本
        if (this.sourceText && this.translatedText) {
          const tmpText = this.sourceText;
          this.sourceText = this.translatedText;
          this.translatedText = tmpText;
        }
      })

    // 目标语言选择
    Row() {
      Text(getLanguageName(this.targetLang))
        .fontSize(14)
        .fontColor($r('app.color.text_primary'))
      Image($r('app.media.ic_arrow_down'))
        .width(12)
        .height(12)
        .fillColor($r('app.color.text_secondary'))
    }
    .padding({ left: 12, right: 12, top: 6, bottom: 6 })
    .borderRadius(16)
    .backgroundColor($r('app.color.chip_bg'))
    .onClick(() => {
      this.pickingSource = false;
      this.showLangPicker = true;
    })
  }
  .width('100%')
  .justifyContent(FlexAlign.Center)
  .padding({ top: 8, bottom: 12 })
}

6.4 输入区域

@Builder
buildInputArea() {
  Column() {
    TextArea({
      text: this.sourceText,
      placeholder: '请输入要翻译的文本...',
    })
    .width('100%')
    .height(140)
    .fontSize(16)
    .fontColor($r('app.color.text_primary'))
    .placeholderFont({ size: 14, weight: FontWeight.Regular })
    .placeholderColor($r('app.color.text_hint'))
    .backgroundColor(Color.White)
    .borderRadius(12)
    .padding(12)
    .onChange((value: string) => {
      this.sourceText = value;
      if (!value.trim()) {
        this.showResult = false;
        this.translatedText = '';
      }
    })

    Row() {
      Text(this.sourceText.length + '/2000')
        .fontSize(12)
        .fontColor($r('app.color.text_hint'))

      Blank()

      if (this.sourceText.length > 0) {
        Image($r('app.media.ic_clear'))
          .width(18)
          .height(18)
          .fillColor($r('app.color.text_secondary'))
          .margin({ right: 8 })
          .onClick(() => {
            this.sourceText = '';
            this.translatedText = '';
            this.showResult = false;
          })
      }
    }
    .width('100%')
    .padding({ top: 4, bottom: 4, left: 4, right: 4 })
  }
  .width('100%')
}

6.5 翻译按钮

@Builder
buildTranslateButton() {
  Button() {
    if (this.isTranslating) {
      LoadingProgress()
        .width(20)
        .height(20)
        .color(Color.White)
    } else {
      Text('翻 译')
        .fontSize(16)
        .fontColor(Color.White)
        .fontWeight(FontWeight.Medium)
    }
  }
  .width('100%')
  .height(44)
  .backgroundColor($r('app.color.primary'))
  .borderRadius(22)
  .margin({ top: 12, bottom: 16 })
  .enabled(!this.isTranslating && this.sourceText.trim().length > 0)
  .onClick(() => {
    this.performTranslation();
  })
}

翻译时显示加载动画,防止重复点击。

6.6 执行翻译

async performTranslation(): Promise<void> {
  if (!this.sourceText.trim()) return;
  
  this.isTranslating = true;
  this.showResult = false;

  try {
    // 自动检测语言
    this.sourceLang = TranslationService.detectLanguage(this.sourceText);
    
    // 调用翻译服务
    const result = await this.translationService.translate(
      this.sourceText, this.sourceLang, this.targetLang
    );
    
    this.translatedText = result;
    this.showResult = true;
    
    // 保存到历史记录
    this.prefManager.addEntry(
      this.sourceLang, this.targetLang, this.sourceText, result
    );
  } catch (err) {
    hilog.error(DOMAIN, TAG, 'Translation failed: ' + JSON.stringify(err));
    promptAction.showToast({ message: '翻译失败,请检查网络连接', duration: 2000 });
  } finally {
    this.isTranslating = false;
  }
}

6.7 结果区域

@Builder
buildResultArea() {
  Column() {
    Row() {
      Text(getLanguageName(this.targetLang))
        .fontSize(12)
        .fontColor($r('app.color.text_hint'))

      Blank()

      Image($r('app.media.ic_copy'))
        .width(20)
        .height(20)
        .fillColor($r('app.color.text_secondary'))
        .margin({ left: 8 })
        .onClick(() => {
          ClipboardUtil.copyText(getContext(this), this.translatedText);
          promptAction.showToast({ message: '已复制到剪切板', duration: 1500 });
        })

      Image($r('app.media.ic_speaker'))
        .width(20)
        .height(20)
        .fillColor($r('app.color.text_secondary'))
        .margin({ left: 8 })
        .onClick(() => {
          promptAction.showToast({ message: '朗读功能开发中', duration: 1500 });
        })
    }
    .width('100%')
    .padding({ bottom: 8 })

    Text(this.translatedText)
      .fontSize(16)
      .fontColor($r('app.color.text_primary'))
      .lineHeight(24)
      .width('100%')
      .copyOption(CopyOptions.InApp)  // 支持长按复制
  }
  .width('100%')
  .padding(16)
  .backgroundColor(Color.White)
  .borderRadius(12)
}

6.8 语言选择器

@Builder
buildLangPicker() {
  Column() {
    Row() {
      Text(this.pickingSource ? '选择源语言' : '选择目标语言')
        .fontSize(18)
        .fontWeight(FontWeight.Medium)
        .fontColor($r('app.color.text_primary'))
      Blank()
      Image($r('app.media.ic_close'))
        .width(20)
        .height(20)
        .onClick(() => {
          this.showLangPicker = false;
        })
    }
    .width('100%')
    .padding(16)

    List() {
      ForEach(LANGUAGES, (lang: LanguageOption) => {
        ListItem() {
          Row() {
            Text(lang.name)
              .fontSize(16)
              .fontColor($r('app.color.text_primary'))
            Blank()
            if ((this.pickingSource && lang.code === this.sourceLang) ||
                (!this.pickingSource && lang.code === this.targetLang)) {
              Image($r('app.media.ic_check'))
                .width(18)
                .height(18)
                .fillColor($r('app.color.primary'))
            }
          }
          .width('100%')
          .padding(12)
          .backgroundColor(Color.White)
          .borderRadius(8)
          .onClick(() => {
            if (this.pickingSource) {
              this.sourceLang = lang.code;
            } else {
              this.targetLang = lang.code;
            }
            this.showLangPicker = false;
          })
        }
      }, (lang: LanguageOption) => lang.code)
    }
    .width('100%')
    .layoutWeight(1)
    .padding({ left: 16, right: 16 })
  }
  .width('100%')
  .height('70%')
  .backgroundColor(Color.White)
  .borderRadius({ topLeft: 24, topRight: 24 })
}

使用 bindContentCover 绑定半屏弹窗,从底部弹出语言选择器。


七、历史记录页

7.1 历史页组件

@Component
struct HistoryTab {
  @State prefManager: PreferencesManager = PreferencesManager.getInstance();
  onSelectEntry: (entry: TranslationEntry) => void = () => {};

  @State entries: TranslationEntry[] = [];
  @State searchText: string = '';
  @State isSearching: boolean = false;

  aboutToAppear(): void {
    this.loadHistory();
  }

  async loadHistory(): Promise<void> {
    if (this.isSearching && this.searchText.trim()) {
      this.entries = await this.prefManager.searchEntries(this.searchText);
    } else {
      this.entries = await this.prefManager.getAllEntries();
    }
  }

  build() {
    Column() {
      // 搜索框
      Row() {
        Image($r('app.media.ic_search'))
          .width(16)
          .height(16)
          .fillColor($r('app.color.text_hint'))
          .margin({ left: 12 })
        TextInput({
          text: this.searchText,
          placeholder: '搜索翻译记录...',
        })
        .layoutWeight(1)
        .fontSize(14)
        .placeholderFont({ size: 14 })
        .backgroundColor(Color.Transparent)
        .onChange((value: string) => {
          this.searchText = value;
          this.isSearching = value.trim().length > 0;
          this.loadHistory();
        })
      }
      .height(40)
      .borderRadius(20)
      .backgroundColor($r('app.color.chip_bg'))
      .margin(16)

      // 历史列表
      if (this.entries.length === 0) {
        Column() {
          Image($r('app.media.ic_empty'))
            .width(80)
            .height(80)
            .fillColor($r('app.color.text_hint'))
            .margin({ bottom: 16 })
          Text(this.isSearching ? '未找到匹配记录' : '暂无翻译记录')
            .fontSize(14)
            .fontColor($r('app.color.text_hint'))
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
      } else {
        List() {
          ForEach(this.entries, (entry: TranslationEntry) => {
            ListItem() {
              HistoryItem({
                entry: entry,
                onTap: () => { this.onSelectEntry(entry); },
                onToggleFav: async () => {
                  await this.prefManager.toggleFavorite(entry.id);
                  this.loadHistory();
                },
                onDelete: async () => {
                  await this.prefManager.deleteEntry(entry.id);
                  this.loadHistory();
                }
              })
            }
          }, (entry: TranslationEntry) => entry.id)
        }
        .width('100%')
        .layoutWeight(1)
      }
    }
    .width('100%')
    .height('100%')
  }
}

7.2 历史项组件

@Component
struct HistoryItem {
  @Prop entry: TranslationEntry = {
    id: '', sourceLang: '', targetLang: '', sourceText: '',
    targetText: '', timestamp: 0, isFavorite: false
  };
  onTap: () => void = () => {};
  onToggleFav: () => void = () => {};
  onDelete: () => void = () => {};

  build() {
    Column() {
      Row() {
        Text(getLanguageName(this.entry.sourceLang) + ' → ' + getLanguageName(this.entry.targetLang))
          .fontSize(11)
          .fontColor($r('app.color.tab_active'))
          .backgroundColor($r('app.color.chip_bg'))
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(8)
      }
      .width('100%')

      Text(this.entry.sourceText)
        .fontSize(14)
        .fontColor($r('app.color.text_primary'))
        .lineHeight(20)
        .width('100%')
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .margin({ top: 6 })

      Text(this.entry.targetText)
        .fontSize(13)
        .fontColor($r('app.color.text_secondary'))
        .lineHeight(18)
        .width('100%')
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .margin({ top: 4 })

      Row() {
        Text(formatTime(this.entry.timestamp))
          .fontSize(11)
          .fontColor($r('app.color.text_hint'))

        Blank()

        Image(this.entry.isFavorite ?
          $r('app.media.ic_favorite_filled') : $r('app.media.ic_favorite'))
          .width(18)
          .height(18)
          .fillColor(this.entry.isFavorite ?
            $r('app.color.favorite') : $r('app.color.text_hint'))
          .margin({ left: 8 })
          .onClick(() => {
            this.onToggleFav();
          })

        Image($r('app.media.ic_delete'))
          .width(16)
          .height(16)
          .fillColor($r('app.color.text_hint'))
          .margin({ left: 8 })
          .onClick(() => {
            this.onDelete();
          })
      }
      .width('100%')
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(Color.White)
    .borderRadius(12)
    .margin({ left: 16, right: 16, bottom: 8 })
    .onClick(() => {
      this.onTap();
    })
  }
}

点击历史项可将内容填充到翻译页,实现快速复用。


八、收藏页

8.1 收藏页组件

@Component
struct FavoritesTab {
  @State prefManager: PreferencesManager = PreferencesManager.getInstance();
  onSelectEntry: (entry: TranslationEntry) => void = () => {};

  @State entries: TranslationEntry[] = [];

  aboutToAppear(): void {
    this.loadFavorites();
  }

  async loadFavorites(): Promise<void> {
    this.entries = await this.prefManager.getFavorites();
  }

  build() {
    Column() {
      if (this.entries.length === 0) {
        Column() {
          Image($r('app.media.ic_favorite'))
            .width(80)
            .height(80)
            .fillColor($r('app.color.text_hint'))
            .margin({ bottom: 16 })
          Text('暂无收藏')
            .fontSize(14)
            .fontColor($r('app.color.text_hint'))
          Text('在翻译结果中点击♥收藏')
            .fontSize(12)
            .fontColor($r('app.color.text_hint'))
            .margin({ top: 4 })
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
      } else {
        List() {
          ForEach(this.entries, (entry: TranslationEntry) => {
            ListItem() {
              HistoryItem({
                entry: entry,
                onTap: () => { this.onSelectEntry(entry); },
                onToggleFav: async () => {
                  await this.prefManager.toggleFavorite(entry.id);
                  this.loadFavorites();
                },
                onDelete: async () => {
                  await this.prefManager.deleteEntry(entry.id);
                  this.loadFavorites();
                }
              })
            }
          }, (entry: TranslationEntry) => entry.id)
        }
        .width('100%')
        .layoutWeight(1)
      }
    }
    .width('100%')
    .height('100%')
  }
}

收藏页和历史页共用 HistoryItem 组件,保持界面一致性。


九、应用配置

9.1 权限配置

module.json5 中添加网络权限:

"requestPermissions": [
  {
    "name": "ohos.permission.INTERNET",
    "reason": "$string:permission_internet"
  }
]

网络权限是必需的,否则无法调用翻译 API。

9.2 页面路由配置

resources/base/profile/main_pages.json 中:

{
  "src": [
    "pages/Index"
  ]
}

9.3 初始化数据服务

EntryAbility.ets 中初始化 Preferences:

import PreferencesManager from '../data/PreferencesManager';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 初始化数据服务
    PreferencesManager.getInstance().init(this.context);
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(DOMAIN, 'testTag', 'Failed to load content: %{public}s', JSON.stringify(err));
        return;
      }
      hilog.info(DOMAIN, 'testTag', 'Succeeded in loading content.');
    });
  }
}

十、运行测试

在这里插入图片描述

10.1 功能测试

测试清单

翻译功能

  • 输入中文翻译为英文
  • 输入英文翻译为中文
  • 自动检测语言
  • 语言互换按钮
  • 清除输入
  • 复制结果

历史记录

  • 保存翻译记录
  • 显示历史列表
  • 搜索历史记录
  • 点击历史项填充
  • 删除历史记录

收藏功能

  • 收藏翻译结果
  • 取消收藏
  • 查看收藏列表
  • 从收藏中删除

边界情况

  • 空输入翻译
  • 超长文本翻译
  • 网络异常提示
  • 无历史记录提示
  • 无收藏记录提示

10.2 性能测试

  • 历史记录加载速度:200 条记录加载时间 < 100ms
  • 搜索响应速度:实时搜索,无明显延迟
  • 网络请求超时:设置 8 秒超时,超时后自动切换备用 API

十一、踩坑经验

11.1 网络请求资源释放

问题:忘记调用 httpRequest.destroy(),导致资源泄漏。

解决:在请求回调中立即释放资源:

httpRequest.request(..., (err, data) => {
  httpRequest.destroy();  // ✅ 立即释放
  // 处理响应...
});

11.2 Preferences 数据持久化

问题:调用 put() 后数据未持久化,应用重启后丢失。

解决:必须调用 flush()

await this.pref.put(KEY_HISTORY, JSON.stringify(entries));
await this.pref.flush();  // ✅ 持久化到磁盘

11.3 状态更新触发重渲染

问题:修改数组元素后 UI 不更新。

解决:重新赋值整个数组:

// ❌ 错误:直接修改元素
this.entries[0].isFavorite = true;

// ✅ 正确:重新赋值
const newEntries = [...this.entries];
newEntries[0].isFavorite = true;
this.entries = newEntries;

11.4 ContentCover 半屏弹窗

问题bindContentCover 绑定弹窗,点击空白区域不关闭。

解决:使用双向绑定 $$ 并手动控制:

@State showLangPicker: boolean = false;

.bindContentCover($$this.showLangPicker, this.buildLangPicker())

// 关闭弹窗
this.showLangPicker = false;

11.5 Unicode 字符判断

问题:使用正则表达式判断中文,部分生僻字不匹配。

解决:使用 Unicode 范围判断:

// ❌ 错误:正则表达式可能不完整
const isChinese = /[\u4e00-\u9fa5]/.test(text);

// ✅ 正确:使用完整的 Unicode 范围
for (let i = 0; i < text.length; i++) {
  const code = text.charCodeAt(i);
  if ((code >= 0x4e00 && code <= 0x9fff) || 
      (code >= 0x3400 && code <= 0x4dbf)) {
    return 'zh';
  }
}

十二、总结与展望

12.1 技术要点回顾

技术点 关键内容 掌握程度
网络请求 http.createHttp() + destroy() ⭐⭐⭐⭐⭐
数据持久化 Preferences + flush() ⭐⭐⭐⭐
单例模式 getInstance() ⭐⭐⭐⭐⭐
状态管理 @State + @Prop ⭐⭐⭐⭐
列表渲染 ForEach + key ⭐⭐⭐⭐
半屏弹窗 bindContentCover ⭐⭐⭐

12.2 项目亮点

双重容错:LibreTranslate + MyMemory 备用方案
自动检测:基于 Unicode 的语言自动识别
历史管理:搜索、收藏、删除功能完善
友好体验:加载状态、错误提示、空状态

12.3 扩展方向

功能扩展

  • 语音输入:接入语音识别
  • 语音朗读:接入 TTS 服务
  • 图片翻译:OCR + 翻译
  • 离线翻译:集成离线翻译模型

技术优化

  • 数据库存储:使用 RDB 替代 Preferences
  • 缓存机制:翻译结果缓存,减少网络请求
  • 多语言支持:应用界面国际化

附录:核心文件结构

MyApplication/
├── AppScope/
│   └── app.json5                     # 应用配置
├── entry/
│   └── src/main/
│       ├── ets/
│       │   ├── entryability/
│       │   │   └── EntryAbility.ets  # 初始化数据服务
│       │   ├── model/
│       │   │   └── TranslationEntry.ets  # 数据模型
│       │   ├── service/
│       │   │   └── TranslationService.ets  # 翻译服务(Libre + MyMemory)
│       │   ├── data/
│       │   │   └── PreferencesManager.ets  # 数据持久化
│       │   ├── utils/
│       │   │   └── SystemUtils.ets    # 剪贴板 + 时间格式化
│       │   └── pages/
│       │       └── Index.ets          # 主页面(翻译 + 历史 + 收藏)
│       ├── resources/
│       │   └── base/
│       │       ├── element/
│       │       │   ├── color.json     # 颜色资源
│       │       │   └── string.json    # 字符串资源
│       │       ├── media/             # 图标资源
│       │       └── profile/
│       │           └── main_pages.json
│       └── module.json5               # 网络权限配置
└── build-profile.json5

本文详细记录了 HarmonyOS NEXT 翻译应用的开发过程,从设计到实现,从网络请求到数据持久化,希望能为学习鸿蒙开发的朋友提供有价值的参考。

截图位置

  • 翻译主界面(中文→英文)
  • 语言选择弹窗(12种语言)
  • 历史记录列表(搜索功能)
  • 收藏记录列表
Logo

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

更多推荐