HarmonyOS NEXT 实战:从零打造多语言翻译应用
HarmonyOS NEXT 实战:从零打造多语言翻译应用
网络请求 + 数据持久化 + 语言自动检测,手把手实现一个完整的翻译工具
前言
在学习 HarmonyOS NEXT 开发的过程中,我选择了一个实用的练手项目——多语言翻译应用。这个项目虽然功能看似简单,但却涵盖了移动应用开发的多个核心知识点:
- ✅ 网络请求:调用翻译 API
- ✅ 数据持久化:保存翻译历史
- ✅ 列表管理:历史记录、收藏功能
- ✅ 状态管理:响应式 UI 更新
- ✅ 工具类封装:剪贴板、时间格式化
本文将详细记录这个翻译应用从设计到实现的完整过程,希望能为学习 HarmonyOS NEXT 开发的朋友提供参考。
SDK:HarmonyOS NEXT API 23
开发工具:DevEco Studio
项目类型:Empty Ability
一、项目设计
1.1 功能需求
在开始编码之前,我先梳理了应用的核心功能:
核心功能:
- 文本翻译:输入文本,点击翻译,显示结果
- 语言切换:选择源语言和目标语言
- 语言互换:一键切换源语言和目标语言
- 自动检测:根据输入内容自动识别语言
辅助功能:
- 历史记录:保存所有翻译记录,支持搜索
- 收藏功能:标记重要翻译,快速访问
- 复制结果:一键复制翻译结果
- 清除输入:快速清空输入框
用户体验:
- 加载状态:翻译时显示加载动画
- 错误提示:网络异常时友好提示
- 空状态:无历史/收藏时的提示
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:
-
LibreTranslate(主)
- 开源翻译服务
- 免费使用,无需 API Key
- 支持多语言
-
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);
}
}
);
});
}
关键点:
- 创建 HTTP 请求:使用
http.createHttp() - 设置请求参数:方法、头部、超时时间
- 处理响应:解析 JSON,提取翻译结果
- 释放资源:调用
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种语言)
- 历史记录列表(搜索功能)
- 收藏记录列表
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)