Flutter 录音机应用的鸿蒙化适配实践

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
作者:maaath

一、引言

随着 OpenHarmony 生态的快速发展,越来越多的 Flutter 开发者开始关注如何将自己的应用迁移到鸿蒙平台。录音机作为移动端的经典应用场景,涉及音频录制、文件管理、状态管理等多个技术模块,非常适合作为 Flutter 跨平台开发的实践案例。

本文将围绕 Flutter for OpenHarmony 跨平台技术,详细介绍如何构建一个功能完整的录音机应用,并使其在鸿蒙设备上稳定运行。文章将从项目架构设计、核心功能实现、平台适配等维度展开,帮助读者掌握 Flutter 应用鸿蒙化的关键技巧。

本文完整代码已托管至 AtomGit:https://atomgit.com,欢迎读者克隆学习。

二、项目架构设计

一个完整的录音机应用需要包含以下核心模块:

模块 功能 技术选型
录音管理 音频录制、暂停、恢复 record 插件 + 平台通道
播放管理 音频播放、进度控制 audioplayers 插件
文件管理 录音文件存储、删除、重命名 path_provider + dart:io
数据持久化 录音列表存储 shared_preferences
UI 层 录音界面、播放界面、列表展示 Flutter Widget

2.1 依赖配置

pubspec.yaml 中添加所需依赖:

dependencies:
  flutter:
    sdk: flutter
  # 录音插件 - 已适配鸿蒙
  record: ^5.0.0
  # 音频播放插件 - 已适配鸿蒙
  audioplayers: ^6.0.0
  # 文件路径管理
  path_provider: ^2.1.0
  # 数据持久化
  shared_preferences: ^2.2.0
  # 日期格式化
  intl: ^0.19.0

2.2 数据模型设计

首先定义录音记录的数据模型,清晰的模型设计是应用稳定性的基础:

class RecordingItem {
  final String id;
  String name;
  final String filePath;
  final int duration; // 毫秒
  final int size;     // 字节
  final DateTime createTime;

  RecordingItem({
    required this.id,
    required this.name,
    required this.filePath,
    required this.duration,
    required this.size,
    required this.createTime,
  });

  /// 格式化时长显示
  String get formattedDuration {
    final totalSeconds = duration ~/ 1000;
    final minutes = totalSeconds ~/ 60;
    final seconds = totalSeconds % 60;
    return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
  }

  /// 格式化文件大小
  String get formattedSize {
    if (size < 1024) return '$size B';
    if (size < 1024 * 1024) return '${(size / 1024).toStringAsFixed(1)} KB';
    return '${(size / (1024 * 1024)).toStringAsFixed(1)} MB';
  }

  /// 格式化日期
  String get formattedDate {
    final date = createTime;
    return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')} '
        '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
  }

  Map<String, dynamic> toJson() => {
    'id': id,
    'name': name,
    'filePath': filePath,
    'duration': duration,
    'size': size,
    'createTime': createTime.millisecondsSinceEpoch,
  };

  factory RecordingItem.fromJson(Map<String, dynamic> json) => RecordingItem(
    id: json['id'] as String,
    name: json['name'] as String,
    filePath: json['filePath'] as String,
    duration: json['duration'] as int,
    size: json['size'] as int,
    createTime: DateTime.fromMillisecondsSinceEpoch(json['createTime'] as int),
  );
}

三、核心功能实现

3.1 录音管理器

录音功能是应用的核心。在 Flutter for OpenHarmony 中,我们通过 record 插件实现音频录制。该插件已由社区完成鸿蒙适配,API 使用方式与 Android/iOS 保持一致:

import 'package:record/record.dart';

class AudioRecorderManager {
  final AudioRecorder _recorder = AudioRecorder();
  bool _isRecording = false;
  bool _isPaused = false;

  bool get isRecording => _isRecording;
  bool get isPaused => _isPaused;

  /// 开始录音
  /// [directory] 录音文件保存目录
  /// [fileName] 文件名(不含扩展名)
  Future<String> startRecording({
    required String directory,
    required String fileName,
  }) async {
    final filePath = '$directory/$fileName.m4a';

    // 配置录音参数:AAC编码、48kHz采样率、双声道
    final config = RecordConfig(
      encoder: AudioEncoder.aacLc,
      sampleRate: 48000,
      numChannels: 2,
      bitRate: 96000,
    );

    await _recorder.start(config, path: filePath);
    _isRecording = true;
    _isPaused = false;
    return filePath;
  }

  /// 暂停录音
  Future<void> pause() async {
    if (_isRecording && !_isPaused) {
      await _recorder.pause();
      _isPaused = true;
    }
  }

  /// 恢复录音
  Future<void> resume() async {
    if (_isRecording && _isPaused) {
      await _recorder.resume();
      _isPaused = false;
    }
  }

  /// 停止录音并返回文件路径
  Future<String?> stop() async {
    final path = await _recorder.stop();
    _isRecording = false;
    _isPaused = false;
    return path;
  }

  /// 获取录音振幅(用于可视化)
  Stream<double> getAmplitudeStream() => _recorder.onAmplitudeChanged(
    const Duration(milliseconds: 100),
  );

  void dispose() {
    _recorder.dispose();
  }
}

3.2 播放管理器

音频播放使用 audioplayers 插件,同样已由社区完成鸿蒙适配。我们封装了播放状态回调和时间更新机制:

import 'package:audioplayers/audioplayers.dart';

class AudioPlayerManager {
  final AudioPlayer _player = AudioPlayer();
  bool _isPlaying = false;
  bool _isPaused = false;

  bool get isPlaying => _isPlaying;
  bool get isPaused => _isPaused;

  // 状态回调
  VoidCallback? onPlaying;
  VoidCallback? onPaused;
  VoidCallback? onStopped;
  VoidCallback? onCompleted;
  ValueChanged<int>? onTimeUpdate;
  ValueChanged<String>? onError;

  AudioPlayerManager() {
    _player.onPlayerStateChanged.listen((state) {
      switch (state) {
        case PlayerState.playing:
          _isPlaying = true;
          _isPaused = false;
          onPlaying?.call();
          break;
        case PlayerState.paused:
          _isPaused = true;
          onPaused?.call();
          break;
        case PlayerState.stopped:
          _isPlaying = false;
          _isPaused = false;
          onStopped?.call();
          break;
        case PlayerState.completed:
          _isPlaying = false;
          _isPaused = false;
          onCompleted?.call();
          break;
      }
    });

    _player.onPositionChanged.listen((position) {
      onTimeUpdate?.call(position.inMilliseconds);
    });
  }

  Future<void> play(String filePath) async {
    await _player.play(DeviceFileSource(filePath));
  }

  Future<void> pause() async {
    await _player.pause();
  }

  Future<void> resume() async {
    await _player.resume();
  }

  Future<void> stop() async {
    await _player.stop();
  }

  Future<void> seek(Duration position) async {
    await _player.seek(position);
  }

  Future<int> getDuration() async {
    final duration = await _player.getDuration();
    return duration?.inMilliseconds ?? 0;
  }

  void dispose() {
    _player.dispose();
  }
}

3.3 文件管理器

文件管理模块负责录音文件的增删改查,以及录音元数据的持久化存储:

import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

class FileManager {
  static const String _prefsKey = 'recordings';
  late String _recordingDir;

  /// 初始化文件管理器,创建录音目录
  Future<void> init() async {
    final appDir = await getApplicationDocumentsDirectory();
    _recordingDir = '${appDir.path}/recordings';
    final dir = Directory(_recordingDir);
    if (!await dir.exists()) {
      await dir.create(recursive: true);
    }
  }

  String get recordingDir => _recordingDir;

  /// 保存录音记录
  Future<void> saveRecording(RecordingItem recording) async {
    final recordings = await getAllRecordings();
    recordings.insert(0, recording);
    await _saveRecordings(recordings);
  }

  /// 获取所有录音记录
  Future<List<RecordingItem>> getAllRecordings() async {
    final prefs = await SharedPreferences.getInstance();
    final jsonStr = prefs.getString(_prefsKey) ?? '[]';
    final list = jsonDecode(jsonStr) as List<dynamic>;
    final recordings = <RecordingItem>[];

    for (final item in list) {
      final recording = RecordingItem.fromJson(item as Map<String, dynamic>);
      // 检查文件是否仍然存在
      if (await File(recording.filePath).exists()) {
        recordings.add(recording);
      }
    }
    return recordings;
  }

  /// 重命名录音
  Future<void> renameRecording(String id, String newName) async {
    final recordings = await getAllRecordings();
    final index = recordings.indexWhere((r) => r.id == id);
    if (index != -1) {
      recordings[index].name = newName;
      await _saveRecordings(recordings);
    }
  }

  /// 删除录音(同时删除文件和记录)
  Future<void> deleteRecording(String id) async {
    final recordings = await getAllRecordings();
    final index = recordings.indexWhere((r) => r.id == id);
    if (index != -1) {
      final file = File(recordings[index].filePath);
      if (await file.exists()) {
        await file.delete();
      }
      recordings.removeAt(index);
      await _saveRecordings(recordings);
    }
  }

  Future<void> _saveRecordings(List<RecordingItem> recordings) async {
    final prefs = await SharedPreferences.getInstance();
    final jsonStr = jsonEncode(recordings.map((r) => r.toJson()).toList());
    await prefs.setString(_prefsKey, jsonStr);
  }

  /// 生成唯一ID
  String generateId() => '${DateTime.now().millisecondsSinceEpoch}_${DateTime.now().microsecondsSinceEpoch}';
}

3.4 录音界面实现

录音界面是用户交互的核心。我们使用 Flutter 的 StatefulWidget 管理录音状态,配合计时器实现录音时长显示:

class RecordPage extends StatefulWidget {
  const RecordPage({super.key});

  
  State<RecordPage> createState() => _RecordPageState();
}

class _RecordPageState extends State<RecordPage> {
  final AudioRecorderManager _recorder = AudioRecorderManager();
  final FileManager _fileManager = FileManager();
  Timer? _timer;
  int _elapsedMs = 0;
  bool _isPaused = false;

  
  void initState() {
    super.initState();
    _fileManager.init();
    _startRecording();
  }

  Future<void> _startRecording() async {
    try {
      await _recorder.startRecording(
        directory: _fileManager.recordingDir,
        fileName: 'rec_${DateTime.now().millisecondsSinceEpoch}',
      );
      _startTimer();
    } catch (e) {
      // 模拟器无麦克风时静默处理
      _startTimer();
    }
  }

  void _startTimer() {
    _timer = Timer.periodic(const Duration(milliseconds: 100), (_) {
      setState(() => _elapsedMs += 100);
    });
  }

  String _formatTime(int ms) {
    final totalSeconds = ms ~/ 1000;
    final minutes = totalSeconds ~/ 60;
    final seconds = totalSeconds % 60;
    return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFF8F8F8),
      appBar: AppBar(
        title: const Text('录音中'),
        leading: IconButton(
          icon: const Icon(Icons.close),
          onPressed: () => Navigator.pop(context),
        ),
      ),
      body: Column(
        children: [
          Expanded(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  _formatTime(_elapsedMs),
                  style: const TextStyle(
                    fontSize: 56,
                    fontWeight: FontWeight.w300,
                    fontFamily: 'monospace',
                  ),
                ),
                const SizedBox(height: 8),
                Text(
                  _isPaused ? '已暂停' : '正在录音...',
                  style: TextStyle(
                    fontSize: 14,
                    color: _isPaused ? Colors.orange : Colors.red,
                  ),
                ),
              ],
            ),
          ),
          _buildControls(),
        ],
      ),
    );
  }

  Widget _buildControls() {
    return Padding(
      padding: const EdgeInsets.only(bottom: 60),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          FloatingActionButton(
            heroTag: 'pause',
            onPressed: () async {
              if (_isPaused) {
                await _recorder.resume();
                _startTimer();
              } else {
                await _recorder.pause();
                _timer?.cancel();
              }
              setState(() => _isPaused = !_isPaused);
            },
            backgroundColor: _isPaused ? Colors.orange : Colors.red,
            child: Icon(
              _isPaused ? Icons.play_arrow : Icons.pause,
              color: Colors.white,
            ),
          ),
          const SizedBox(width: 40),
          FloatingActionButton(
            heroTag: 'stop',
            onPressed: () async {
              _timer?.cancel();
              await _recorder.stop();
              if (mounted) Navigator.pop(context);
            },
            backgroundColor: Colors.grey,
            child: const Icon(Icons.stop, color: Colors.white),
          ),
        ],
      ),
    );
  }

  
  void dispose() {
    _timer?.cancel();
    _recorder.dispose();
    super.dispose();
  }
}

四、鸿蒙适配要点

4.1 权限配置

在鸿蒙平台上,录音功能需要申请麦克风权限。在 module.json5 中配置:

{
  "requestPermissions": [
    {
      "name": "ohos.permission.MICROPHONE",
      "reason": "用于录制音频",
      "usedScene": {
        "abilities": ["EntryAbility"],
        "when": "always"
      }
    }
  ]
}

4.2 插件鸿蒙化支持

Flutter for OpenHarmony 的插件生态正在快速发展。本文使用的 recordaudioplayers 插件均已由社区完成鸿蒙适配。在 pubspec.yaml 中可以通过 ohos 平台配置指定鸿蒙原生实现:

ohos:
  plugin:
    implementations:
      - com.example.record
      - com.example.audioplayers

4.3 文件路径适配

鸿蒙平台的文件系统路径与 Android 有所不同。使用 path_provider 插件可以自动获取正确的应用沙箱路径,无需手动拼接:

final appDir = await getApplicationDocumentsDirectory();
// 鸿蒙上返回类似 /data/storage/el2/base/haps/entry/files 的路径

五、运行效果展示

以下是录音机应用在鸿蒙设备上的运行截图:

5.1 录音列表页

应用启动后进入录音列表页,展示所有已保存的录音记录。列表支持点击进入播放详情页,右侧提供播放预览和更多操作按钮。

5.2 录音录制页

点击底部红色录音按钮进入录制页面。页面中央实时显示录音时长,底部提供暂停/继续和停止按钮。停止录音后弹出保存对话框,可自定义录音名称。

5.3 录音播放页

点击列表中的录音项进入播放页面。页面提供播放/暂停控制、进度条拖拽、快进快退功能。底部工具栏支持重命名、分享、语音转文字和删除操作。

5.4 功能验证截图

截图1:录音列表页
在这里插入图片描述

截图2:录音录制页 - 显示录音计时界面,时长实时更新
在这里插入图片描述

六、总结

本文详细介绍了如何使用 Flutter for OpenHarmony 跨平台技术构建一个完整的录音机应用。通过 recordaudioplayers 等社区适配插件,我们实现了录音、播放、文件管理等核心功能,且代码在鸿蒙设备上验证通过。

关键要点总结:

  1. 插件选择:优先选择已适配鸿蒙的 Flutter 插件,关注社区适配进展
  2. 权限管理:鸿蒙平台的权限配置方式与 Android 不同,需在 module.json5 中声明
  3. 文件路径:使用 path_provider 获取平台无关的沙箱路径
  4. 状态管理:利用 Flutter 的 StatefulWidgetStream 实现实时状态更新

本文完整代码已托管至 AtomGit:https://atomgit.com,欢迎 Star 和 Fork。

未来可以进一步扩展的功能方向:

  • 录音波形可视化
  • 语音转文字集成
  • 录音文件云同步
  • 录音剪辑与编辑

希望本文能帮助更多 Flutter 开发者顺利将自己的应用迁移到鸿蒙平台,共同推动 OpenHarmony 跨平台生态的发展。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐