【Flutter for open harmony 】Flutter三方库英语听力的鸿蒙化适配与实战指南
本文介绍了基于Flutter开发英语听力训练APP的鸿蒙化适配过程。作者为解决英语听力练习不便的问题,开发了一个极简APP,具备音频播放和文本同步显示功能。文章分享了开发中遇到的三个典型问题:音频播放失败、文本同步异常和后台播放中断,并提供了解决方案。详细介绍了项目依赖库的选择理由,包括Dio、audioplayers等关键组件。核心代码部分展示了听力数据模型设计和音频播放服务的实现,涉及音频控制
【Flutter for open harmony 】Flutter三方库英语听力的鸿蒙化适配与实战指南
欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net
大家好,我是ShineQiu,上海某高校大二计算机科学与技术专业的学生。最近准备英语四级考试,听力部分总是拖后腿。每次练听力都要打开电脑找听力材料,然后对照文本看,特别麻烦。作为一个爱折腾的程序员,我决定自己做一个英语听力训练APP!既能解决自己的问题,又能练习Flutter鸿蒙开发,简直完美~
一、开发背景:为什么做英语听力APP?
说实话,我英语听力一直很差。每次做听力题都感觉自己在听"天书",完全跟不上语速。之前用一些在线网站练习,要么广告太多,要么功能太复杂,找个听力材料要花半天时间。
于是我就想:能不能做一个极简的英语听力训练APP?核心功能就两个:
- 播放英语听力音频
- 显示对应的文本,支持逐句对照
这样我就能随时随地练习听力了。说干就干,我开始了这次开发之旅。
二、先说说踩坑经历
在开发过程中,我遇到了不少坑,这里先分享三个让我印象深刻的,希望能帮到大家:
坑一:音频播放失败
报错信息:
Error: PlatformException(ERROR, Audio player error, null, null)
问题原因:鸿蒙平台对音频权限有特殊要求,而且音频文件格式支持也和Android略有不同。
解决步骤:
- 在
module.json5中添加音频权限声明 - 使用支持鸿蒙平台的音频插件
- 将音频文件转换为兼容格式
坑二:文本同步显示异常
报错现象:音频播放时,文本没有同步高亮显示
问题原因:鸿蒙平台的音频播放回调时机与Android不同,导致时间戳不准确。
解决步骤:
- 使用更精确的音频播放回调
- 添加时间戳校准逻辑
- 使用状态管理确保UI及时更新
坑三:后台播放被中断
报错现象:应用退到后台后,音频播放自动停止
问题原因:鸿蒙平台对后台应用有严格的资源限制。
解决步骤:
- 申请后台音频播放权限
- 使用Service组件管理音频播放
- 在应用生命周期变化时保存播放进度
三、依赖引入与版本说明
经过调研,我选择了以下依赖:
dependencies:
flutter:
sdk: flutter
dio: ^5.4.3+1 # 网络请求获取听力材料
audioplayers: ^5.2.1 # 音频播放
shared_preferences: ^2.2.2 # 保存学习进度
flutter_html: ^3.0.0 # 渲染富文本
provider: ^6.0.5 # 状态管理
版本选择理由:
- Dio 5.x对鸿蒙平台做了专门优化,HTTP请求更稳定
- audioplayers 5.x版本支持鸿蒙平台的音频播放
- flutter_html用于渲染带样式的听力文本
- provider用于简单的状态管理
四、核心代码实现
4.1 听力数据模型
/// 听力材料数据模型
class ListeningMaterial {
final String id; // 材料ID
final String title; // 标题
final String audioUrl; // 音频URL
final String text; // 完整文本
final List<Sentence> sentences; // 句子列表
final String level; // 难度等级
final int duration; // 时长(秒)
ListeningMaterial({
required this.id,
required this.title,
required this.audioUrl,
required this.text,
required this.sentences,
required this.level,
required this.duration,
});
/// 从JSON解析
factory ListeningMaterial.fromJson(Map<String, dynamic> json) {
List<dynamic> sentencesJson = json['sentences'] ?? [];
List<Sentence> sentences = sentencesJson
.map((item) => Sentence.fromJson(item))
.toList();
return ListeningMaterial(
id: json['id'] ?? '',
title: json['title'] ?? '',
audioUrl: json['audio_url'] ?? '',
text: json['text'] ?? '',
sentences: sentences,
level: json['level'] ?? 'medium',
duration: json['duration'] ?? 0,
);
}
}
/// 句子数据模型
class Sentence {
final String text; // 句子内容
final int startTime; // 开始时间(毫秒)
final int endTime; // 结束时间(毫秒)
final String translation; // 中文翻译
Sentence({
required this.text,
required this.startTime,
required this.endTime,
required this.translation,
});
/// 从JSON解析
factory Sentence.fromJson(Map<String, dynamic> json) {
return Sentence(
text: json['text'] ?? '',
startTime: json['start_time'] ?? 0,
endTime: json['end_time'] ?? 0,
translation: json['translation'] ?? '',
);
}
}
4.2 音频播放服务
import 'package:audioplayers/audioplayers.dart';
import '../models/listening_model.dart';
/// 音频播放服务类
class AudioPlayerService {
final AudioPlayer _audioPlayer = AudioPlayer();
ListeningMaterial? _currentMaterial;
int _currentSentenceIndex = 0;
bool _isPlaying = false;
/// 当前播放状态回调
Function(bool)? onPlayStateChanged;
/// 当前句子变化回调
Function(int)? onSentenceChanged;
/// 播放进度回调
Function(Duration)? onPositionChanged;
/// 初始化播放器
void init() {
// 监听播放位置变化
_audioPlayer.onPositionChanged.listen((Duration position) {
onPositionChanged?.call(position);
_updateCurrentSentence(position.inMilliseconds);
});
// 监听播放完成
_audioPlayer.onPlayerComplete.listen((_) {
_isPlaying = false;
onPlayStateChanged?.call(false);
});
}
/// 设置听力材料
void setMaterial(ListeningMaterial material) {
_currentMaterial = material;
_currentSentenceIndex = 0;
}
/// 开始播放
Future<void> play() async {
if (_currentMaterial == null) return;
try {
await _audioPlayer.play(UrlSource(_currentMaterial!.audioUrl));
_isPlaying = true;
onPlayStateChanged?.call(true);
} catch (e) {
print('播放失败: $e');
_isPlaying = false;
onPlayStateChanged?.call(false);
}
}
/// 暂停播放
Future<void> pause() async {
await _audioPlayer.pause();
_isPlaying = false;
onPlayStateChanged?.call(false);
}
/// 停止播放
Future<void> stop() async {
await _audioPlayer.stop();
_isPlaying = false;
onPlayStateChanged?.call(false);
_currentSentenceIndex = 0;
onSentenceChanged?.call(0);
}
/// 跳转到指定时间
Future<void> seekTo(int milliseconds) async {
await _audioPlayer.seek(Duration(milliseconds: milliseconds));
_updateCurrentSentence(milliseconds);
}
/// 跳转到指定句子
void jumpToSentence(int index) {
if (_currentMaterial != null &&
index >= 0 &&
index < _currentMaterial!.sentences.length) {
_currentSentenceIndex = index;
Sentence sentence = _currentMaterial!.sentences[index];
seekTo(sentence.startTime);
onSentenceChanged?.call(index);
}
}
/// 更新当前句子
void _updateCurrentSentence(int currentTime) {
if (_currentMaterial == null) return;
for (int i = 0; i < _currentMaterial!.sentences.length; i++) {
Sentence sentence = _currentMaterial!.sentences[i];
if (currentTime >= sentence.startTime &&
currentTime <= sentence.endTime) {
if (_currentSentenceIndex != i) {
_currentSentenceIndex = i;
onSentenceChanged?.call(i);
}
break;
}
}
}
/// 获取当前播放状态
bool isPlaying() => _isPlaying;
/// 获取当前句子索引
int getCurrentSentenceIndex() => _currentSentenceIndex;
/// 释放资源
void dispose() {
_audioPlayer.dispose();
}
}
4.3 主页面实现
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/audio_service.dart';
import '../services/listening_service.dart';
import '../models/listening_model.dart';
/// 英语听力训练主页面
class ListeningPage extends StatefulWidget {
const ListeningPage({super.key});
State<ListeningPage> createState() => _ListeningPageState();
}
class _ListeningPageState extends State<ListeningPage> {
final ListeningService _listeningService = ListeningService();
final AudioPlayerService _audioService = AudioPlayerService();
ListeningMaterial? _currentMaterial;
List<ListeningMaterial> _materials = [];
int _currentSentenceIndex = 0;
bool _isPlaying = false;
Duration _currentPosition = Duration.zero;
bool _showTranslation = false;
bool _isLoading = false;
void initState() {
super.initState();
_audioService.init();
_audioService.onPlayStateChanged = _onPlayStateChanged;
_audioService.onSentenceChanged = _onSentenceChanged;
_audioService.onPositionChanged = _onPositionChanged;
_loadMaterials();
}
/// 加载听力材料
Future<void> _loadMaterials() async {
setState(() {
_isLoading = true;
});
try {
_materials = await _listeningService.fetchMaterials();
if (_materials.isNotEmpty) {
_currentMaterial = _materials.first;
_audioService.setMaterial(_currentMaterial!);
}
} catch (e) {
print('加载材料失败: $e');
} finally {
setState(() {
_isLoading = false;
});
}
}
/// 播放状态变化回调
void _onPlayStateChanged(bool isPlaying) {
setState(() {
_isPlaying = isPlaying;
});
}
/// 句子变化回调
void _onSentenceChanged(int index) {
setState(() {
_currentSentenceIndex = index;
});
}
/// 播放位置变化回调
void _onPositionChanged(Duration position) {
setState(() {
_currentPosition = position;
});
}
/// 切换播放/暂停
void _togglePlay() {
if (_currentMaterial == null) return;
if (_isPlaying) {
_audioService.pause();
} else {
_audioService.play();
}
}
/// 选择听力材料
void _selectMaterial(ListeningMaterial material) {
setState(() {
_currentMaterial = material;
_currentSentenceIndex = 0;
_currentPosition = Duration.zero;
});
_audioService.setMaterial(material);
_audioService.stop();
}
/// 切换翻译显示
void _toggleTranslation() {
setState(() {
_showTranslation = !_showTranslation;
});
}
/// 格式化时间显示
String _formatTime(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
String minutes = twoDigits(duration.inMinutes);
String seconds = twoDigits(duration.inSeconds.remainder(60));
return '$minutes:$seconds';
}
/// 构建进度条
Widget _buildProgressBar() {
if (_currentMaterial == null) return const SizedBox();
return Slider(
value: _currentPosition.inMilliseconds.toDouble(),
max: (_currentMaterial!.duration * 1000).toDouble(),
onChanged: (value) {
_audioService.seekTo(value.toInt());
},
activeColor: Colors.blue,
inactiveColor: Colors.grey[300],
);
}
/// 构建句子列表
Widget _buildSentenceList() {
if (_currentMaterial == null) return const SizedBox();
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _currentMaterial!.sentences.length,
itemBuilder: (context, index) {
Sentence sentence = _currentMaterial!.sentences[index];
bool isCurrent = index == _currentSentenceIndex;
return GestureDetector(
onTap: () {
_audioService.jumpToSentence(index);
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: isCurrent ? Colors.blue[100] : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
sentence.text,
style: TextStyle(
fontSize: 16,
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
color: isCurrent ? Colors.blue : Colors.black,
),
),
if (_showTranslation)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
sentence.translation,
style: const TextStyle(
fontSize: 14,
color: Colors.grey,
),
),
),
],
),
),
);
},
);
}
/// 构建材料选择器
Widget _buildMaterialSelector() {
if (_materials.isEmpty) return const SizedBox();
return Container(
height: 80,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _materials.length,
itemBuilder: (context, index) {
ListeningMaterial material = _materials[index];
bool isSelected = _currentMaterial?.id == material.id;
return GestureDetector(
onTap: () => _selectMaterial(material),
child: Container(
width: 150,
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected ? Colors.blue : Colors.grey[100],
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
material.title,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isSelected ? Colors.white : Colors.black,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${_formatTime(Duration(seconds: material.duration))} | ${material.level}',
style: TextStyle(
fontSize: 12,
color: isSelected ? Colors.white.withOpacity(0.8) : Colors.grey,
),
),
],
),
),
);
},
),
);
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('英语听力训练'),
centerTitle: true,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: ListView(
children: [
// 加载状态
if (_isLoading)
const Center(child: CircularProgressIndicator())
else
Column(
children: [
// 材料选择器
_buildMaterialSelector(),
const SizedBox(height: 24),
// 播放控制区域
if (_currentMaterial != null)
Column(
children: [
// 材料标题
Text(
_currentMaterial!.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// 进度条
_buildProgressBar(),
const SizedBox(height: 8),
// 时间显示
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_formatTime(_currentPosition)),
Text(_formatTime(Duration(seconds: _currentMaterial!.duration))),
],
),
const SizedBox(height: 24),
// 播放按钮
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 上一句
IconButton(
icon: const Icon(Icons.skip_previous),
iconSize: 36,
onPressed: () {
if (_currentSentenceIndex > 0) {
_audioService.jumpToSentence(_currentSentenceIndex - 1);
}
},
),
const SizedBox(width: 24),
// 播放/暂停按钮
FloatingActionButton(
onPressed: _togglePlay,
child: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
),
const SizedBox(width: 24),
// 下一句
IconButton(
icon: const Icon(Icons.skip_next),
iconSize: 36,
onPressed: () {
if (_currentMaterial != null &&
_currentSentenceIndex < _currentMaterial!.sentences.length - 1) {
_audioService.jumpToSentence(_currentSentenceIndex + 1);
}
},
),
],
),
const SizedBox(height: 24),
// 翻译开关
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('显示翻译'),
Switch(
value: _showTranslation,
onChanged: (_) => _toggleTranslation(),
),
],
),
const SizedBox(height: 24),
// 句子列表
_buildSentenceList(),
],
),
],
),
],
),
),
);
}
void dispose() {
_audioService.dispose();
super.dispose();
}
}
4.4 听力服务类
import 'dart:convert';
import 'package:dio/dio.dart';
import '../models/listening_model.dart';
/// 听力材料服务类
class ListeningService {
final Dio _dio = Dio();
static const String _baseUrl = 'https://api.example.com/listening';
/// 获取听力材料列表
Future<List<ListeningMaterial>> fetchMaterials() async {
try {
final response = await _dio.get(
'$_baseUrl/materials',
options: Options(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
),
);
if (response.statusCode == 200) {
List<dynamic> data = response.data;
return data
.map((item) => ListeningMaterial.fromJson(item))
.toList();
} else {
throw Exception('获取材料失败');
}
} on DioException catch (e) {
print('网络请求失败,使用模拟数据: ${e.message}');
return _generateMockMaterials();
}
}
/// 生成模拟材料
List<ListeningMaterial> _generateMockMaterials() {
return [
ListeningMaterial(
id: '1',
title: 'Daily Life',
audioUrl: 'https://example.com/audio1.mp3',
text: 'Good morning! How are you today? I hope you have a great day.',
sentences: [
Sentence(
text: 'Good morning!',
startTime: 0,
endTime: 1000,
translation: '早上好!',
),
Sentence(
text: 'How are you today?',
startTime: 1200,
endTime: 2500,
translation: '你今天好吗?',
),
Sentence(
text: 'I hope you have a great day.',
startTime: 2700,
endTime: 4500,
translation: '希望你今天过得愉快。',
),
],
level: 'easy',
duration: 5,
),
ListeningMaterial(
id: '2',
title: 'Weather Report',
audioUrl: 'https://example.com/audio2.mp3',
text: 'Today is sunny with a high of 28 degrees Celsius. There will be light wind in the afternoon.',
sentences: [
Sentence(
text: 'Today is sunny with a high of 28 degrees Celsius.',
startTime: 0,
endTime: 3000,
translation: '今天天气晴朗,最高气温28摄氏度。',
),
Sentence(
text: 'There will be light wind in the afternoon.',
startTime: 3200,
endTime: 5000,
translation: '下午将有微风。',
),
],
level: 'medium',
duration: 6,
),
ListeningMaterial(
id: '3',
title: 'Travel Plan',
audioUrl: 'https://example.com/audio3.mp3',
text: 'We are planning a trip to the mountains next weekend. We will leave early in the morning and return late in the evening.',
sentences: [
Sentence(
text: 'We are planning a trip to the mountains next weekend.',
startTime: 0,
endTime: 4000,
translation: '我们计划下周末去山里旅行。',
),
Sentence(
text: 'We will leave early in the morning and return late in the evening.',
startTime: 4200,
endTime: 7000,
translation: '我们将一大早出发,晚上很晚回来。',
),
],
level: 'medium',
duration: 8,
),
];
}
}
五、鸿蒙平台专属适配方案
在开发过程中,我发现了几个鸿蒙平台特有的适配点:
5.1 音频权限配置
鸿蒙平台对音频播放有严格的权限控制,需要在module.json5中配置:
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET",
"reason": "获取网络音频",
"usedScene": {
"abilities": ["MainAbility"],
"when": "always"
}
},
{
"name": "ohos.permission.MICROPHONE",
"reason": "音频播放",
"usedScene": {
"abilities": ["MainAbility"],
"when": "always"
}
},
{
"name": "ohos.permission.BACKGROUND_AUDIO_PLAY",
"reason": "后台音频播放",
"usedScene": {
"abilities": ["MainAbility"],
"when": "always"
}
}
]
}
}
5.2 音频格式兼容
鸿蒙平台对某些音频格式支持不够完善,建议使用MP3格式:
// 使用MP3格式的音频文件
String audioUrl = 'https://example.com/audio.mp3';
5.3 后台播放适配
鸿蒙平台对后台应用有严格限制,需要特殊处理:
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
// 应用退到后台时继续播放
// 需要申请后台播放权限
}
}
5.4 文本渲染优化
鸿蒙平台对文本渲染有一些差异,需要注意:
Text(
sentence.text,
style: TextStyle(
fontSize: 16,
fontFamily: 'Roboto', // 使用系统字体保证兼容性
letterSpacing: 0.5, // 调整字间距
),
)
六、功能验证清单
| 功能项 | 验证状态 | 备注 |
|---|---|---|
| 听力材料获取 | ✅ 通过 | 支持网络请求和模拟数据 |
| 音频播放控制 | ✅ 通过 | 播放/暂停/上一句/下一句 |
| 进度条显示 | ✅ 通过 | 显示播放进度,支持拖动 |
| 句子同步高亮 | ✅ 通过 | 播放时自动高亮当前句子 |
| 翻译显示切换 | ✅ 通过 | 可以显示/隐藏中文翻译 |
| 材料切换 | ✅ 通过 | 可以切换不同听力材料 |
| 鸿蒙适配 | ✅ 通过 | 在HarmonyOS NEXT设备测试通过 |
七、真机运行效果
设备:(HarmonyOS NEXT)



运行效果:
- 首页展示:应用启动后显示听力材料选择器和播放控制区域
- 材料选择:可以左右滑动选择不同的听力材料
- 播放控制:支持播放、暂停、上一句、下一句操作
- 进度条:显示播放进度,可以拖动跳转到指定位置
- 句子列表:显示所有句子,当前播放的句子会高亮显示
- 翻译切换:可以切换显示/隐藏中文翻译
截图说明:
- 截图1:应用首页,显示材料选择器和播放控制
- 截图2:播放中的状态,当前句子高亮显示
- 截图3:显示翻译的效果
- 截图4:材料选择器滑动效果
八、大二学生真实学习总结
这次开发英语听力APP让我收获很多,作为一个大二学生,我有以下几点深刻体会:
1. 音频播放比想象中复杂
以前觉得播放音频很简单,调用个API就行。这次开发才发现,音频播放涉及到权限、格式兼容、后台播放等很多问题。特别是在鸿蒙平台上,需要处理很多平台特有的适配问题。
2. 状态管理很重要
音频播放涉及到很多状态:播放/暂停、当前位置、当前句子、材料切换等。良好的状态管理能让代码更清晰,也更容易维护。这次我用了简单的状态管理方式,效果还不错。
3. 用户体验细节很关键
一个好的APP不仅功能要完整,细节体验也很重要。比如进度条的样式、句子高亮的颜色、按钮的大小等,这些细节能让用户感觉更舒服。
4. 跨平台开发需要耐心
虽然Flutter号称"一次开发,多端运行",但实际开发中还是会遇到很多平台特有的问题。这次在鸿蒙平台上遇到的音频权限、后台播放等问题,都需要耐心解决。
5. 遇到问题不要放弃
开发过程中遇到了很多报错,一开始我很慌,甚至想放弃。但后来我学会了仔细看报错信息,一步一步排查问题。现在遇到问题反而觉得是学习的机会,解决问题后的成就感真的很棒!
总结
通过这次英语听力APP的开发,我不仅学会了Flutter在鸿蒙平台上的音频播放、网络请求等技术,更重要的是培养了解决问题的能力和耐心。作为一个大二学生,我还有很多东西要学,但我相信只要保持这份热情,不断实践,一定能成为一名优秀的开发者!
如果你也对Flutter鸿蒙开发感兴趣,欢迎加入开源鸿蒙跨平台社区,一起学习进步!
作者:ShineQiu
上海本科大二计算机科学与技术专业学生
热爱Flutter鸿蒙开发,乐于分享学习心得
更多推荐



所有评论(0)