You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter中如何缓存自定义语音消息Widget?

问题描述

我用Dart开发聊天App,使用audio_waveforms: ^1.0.5编写自定义语音消息Widget时遇到两个问题:

  1. 无法有效缓存Widget,加载耗时极长;
  2. 发送新语音消息时组件异常,显示上一条消息内容,需重新渲染页面才恢复正常。

以下是我的代码:

import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';

class AudioMessageBeta extends StatefulWidget {
  final String id;
  final String audioUrl;
  final String message;
  const AudioMessageBeta(
      {super.key,
      required this.audioUrl,
      required this.message,
      required this.id});

  @override
  State<AudioMessageBeta> createState() => _AudioMessageBetaState();
}

class _AudioMessageBetaState extends State<AudioMessageBeta> {
  bool isLoading = false;
  final PlayerController playerController = new PlayerController();
  StreamSubscription<PlayerState>? playerStateSubsription;

  Future<dynamic> downloadFile(String url) async {
    String dir = (await getApplicationDocumentsDirectory()).path;
    if (await File("$dir/${widget.id}.mp3").exists()) {
      return "$dir/${widget.id}.mp3";
    } else {
      File file = File("$dir/${widget.id}.mp3");
      var request = await http.get(
        Uri.parse(url),
      );
      var bytes = request.bodyBytes;
      await file.writeAsBytes(bytes);
      return file.path;
    }
  }

  void prepareVoice() async {
    await playerController.preparePlayer(
        path: await downloadFile(widget.audioUrl),
        shouldExtractWaveform: true,
        noOfSamples: (MediaQuery.of(context).size.width * 0.5) ~/ 5);
  }

  @override
  void initState() {
    isLoading = true;
    Future.delayed(const Duration(milliseconds: 200), () {
      prepareVoice();
      playerStateSubsription =
          playerController.onPlayerStateChanged.listen((PlayerState) {
        setState(() {});
      });
    });
    super.initState();
    isLoading = false;
  }

  @override
  void dispose() {
    if (mounted) {
      playerController.dispose();
      playerStateSubsription!.cancel();
    }
    super.dispose();
  }

  void playVoice() async {
    switch (playerController.playerState) {
      case PlayerState.playing:
        await playerController.pausePlayer();
        break;
      default:
        await playerController.startPlayer(finishMode: FinishMode.pause);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      constraints: BoxConstraints(
        maxWidth: MediaQuery.of(context).size.width * 0.75,
      ),
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 10),
        child: ListTile(
          contentPadding: const EdgeInsets.all(0),
          horizontalTitleGap: 8,
          leading: GestureDetector(
            onTap: () => playVoice(),
            child: SizedBox(
              height: 45,
              width: 45,
              child: CircleAvatar(
                backgroundColor: Theme.of(context).colorScheme.background,
                child: isLoading
                    ? const CircularProgressIndicator()
                    : Icon(playerController.playerState.isPlaying
                        ? Icons.pause_rounded
                        : Icons.play_arrow),
              ),
            ),
          ),
          title: Padding(
            padding: const EdgeInsets.symmetric(vertical: 5),
            child: AudioFileWaveforms(
              size: Size(MediaQuery.of(context).size.width * 0.5, 25),
              playerController: playerController,
              enableSeekGesture: true,
              continuousWaveform: false,
              waveformType: WaveformType.fitWidth,
              playerWaveStyle: PlayerWaveStyle(
                fixedWaveColor: Theme.of(context).colorScheme.background,
                liveWaveColor: Colors.white,
                spacing: 5,
                waveThickness: 2.5,
                scaleFactor: 200,
              ),
            ),
          ),
        ),
      ),
    );
  }
}
解决方案

一、解决缓存与加载耗时问题

  1. 修正加载状态管理逻辑
    原代码中initState里直接设置isLoading = false会导致加载状态提前结束,应该在音频准备完成后再更新状态:

    void prepareVoice() async {
      setState(() => isLoading = true);
      try {
        final filePath = await downloadFile(widget.audioUrl);
        await playerController.preparePlayer(
          path: filePath,
          shouldExtractWaveform: true,
          noOfSamples: (MediaQuery.of(context).size.width * 0.5) ~/ 5,
        );
      } catch (e) {
        print('音频加载失败: $e');
      } finally {
        setState(() => isLoading = false);
      }
    }
    

    同时删除initState里的延迟和手动状态设置,直接调用prepareVoice:

    @override
    void initState() {
      super.initState();
      prepareVoice();
      playerStateSubsription = playerController.onPlayerStateChanged.listen((_) {
        setState(() {});
      });
    }
    
  2. 优化文件下载的异常处理
    给下载逻辑增加异常捕获,避免因网络问题或请求失败卡住流程:

    Future<String> downloadFile(String url) async {
      final dir = (await getApplicationDocumentsDirectory()).path;
      final file = File("$dir/${widget.id}.mp3");
      if (await file.exists()) {
        return file.path;
      }
      try {
        final response = await http.get(Uri.parse(url));
        if (response.statusCode == 200) {
          await file.writeAsBytes(response.bodyBytes);
          return file.path;
        } else {
          throw Exception('请求失败,状态码: ${response.statusCode}');
        }
      } catch (e) {
        throw Exception('下载失败: $e');
      }
    }
    

二、解决新消息显示旧内容的问题

该问题是ListView复用Widget导致的状态混淆,需从控制器绑定和列表key两方面修复:

  1. 根据消息ID重置播放器控制器
    当Widget的消息ID变化时,销毁旧控制器并创建新的:

    // 将控制器改为可重新赋值的类型
    late PlayerController playerController;
    
    @override
    void initState() {
      super.initState();
      playerController = PlayerController();
      prepareVoice();
      playerStateSubsription = playerController.onPlayerStateChanged.listen((_) {
        setState(() {});
      });
    }
    
    @override
    void didUpdateWidget(covariant AudioMessageBeta oldWidget) {
      super.didUpdateWidget(oldWidget);
      if (widget.id != oldWidget.id) {
        playerController.dispose();
        playerStateSubsription?.cancel();
        playerController = PlayerController();
        prepareVoice();
        playerStateSubsription = playerController.onPlayerStateChanged.listen((_) {
          setState(() {});
        });
      }
    }
    
  2. 给列表项设置唯一Key
    在聊天列表中使用AudioMessageBeta时,必须传入以消息ID为值的Key,避免复用错误:

    AudioMessageBeta(
      key: ValueKey(message.id),
      audioUrl: message.audioUrl,
      message: message.content,
      id: message.id,
    )
    

三、额外优化建议

  • 可以在聊天页面初始化时预加载最近的几条语音,缩短用户等待时间;
  • 定期清理应用目录中过期的音频缓存,避免占用过多存储空间;
  • 用FutureBuilder替代手动状态管理,更优雅地处理异步加载流程。

内容的提问来源于stack exchange,提问作者Giovanni Fittizio

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.24 22:07:46