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

Flutter鼠标悬停播放视频(无内存泄漏)及问题求助

Flutter悬停播放视频异常问题:闪烁、hover反复触发解决方案

问题描述

我是Flutter新手,要实现鼠标悬停图片时自动播放视频、离开时停止的功能。最初在initState里初始化播放器,功能正常但有内存泄漏,于是改成悬停时创建播放器、离开时释放资源,但不确定这个方案是否合理。当前代码存在以下问题:

  • 鼠标悬停时屏幕短暂闪烁
  • 播放器与原图片位置不一致,导致isHover频繁切换,视频无法正常播放

原代码

class ToolsWorksItemView extends StatefulWidget {
  final WorksData model;
  const ToolsWorksItemView({Key? key, required this.model}) : super(key: key);

  @override
  State<ToolsWorksItemView> createState() => _ToolsWorksItemViewState();
}

class _ToolsWorksItemViewState extends State<ToolsWorksItemView> {
  MainTopController mainTop = Get.find();
  VideoPlayerController? _controller;
  CustomVideoPlayerController? _customVideoPlayerController;
  bool showText = false;

  void _initializeVideoPlayer() {
    _controller = VideoPlayerController.networkUrl(Uri.parse(
        "${ApiConfig.baseUrl}/app/gateway/download/file_object_data/${widget.model.fileObjectId}?token=${mainTop.userToken}"))
      ..initialize().then((_) {
        setState(() {});
        //_controller!.play();
      });
    _controller?.setLooping(true);
    _customVideoPlayerController = CustomVideoPlayerController(
        context: context,
        videoPlayerController: _controller!,
        customVideoPlayerSettings: const CustomVideoPlayerSettings(
          playbackSpeedButtonAvailable: false,
          settingsButtonAvailable: false,
          showDurationPlayed: false,
          controlBarAvailable: false,
          playOnlyOnce: false,
          showFullscreenButton: false,
        ));
  }

  // @override
  // void initState() {
  //   super.initState();
  //   _controller = VideoPlayerController.networkUrl(Uri.parse(
  //       "${ApiConfig.baseUrl}/app/gateway/download/file_object_data/${widget.model.fileObjectId}?token=${mainTop.userToken}"))
  //     ..initialize().then((value) => setState(() {}));
  //   _controller?.setLooping(true);
  //   _customVideoPlayerController = CustomVideoPlayerController(
  //       context: context,
  //       videoPlayerController: _controller!,
  //       customVideoPlayerSettings: const CustomVideoPlayerSettings(
  //         playbackSpeedButtonAvailable: false,
  //         settingsButtonAvailable: false,
  //         showDurationPlayed: false,
  //         controlBarAvailable: false,
  //         playOnlyOnce: false,
  //         showFullscreenButton: false,
  //       ));
  // }

  @override
  void dispose() {
    _controller?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        double width = constraints.maxWidth;
        return InkWell(
          child: Stack(
            children: [
              _controller == null || _controller!.value.isInitialized
                  ? ClipRRect(
                      borderRadius: BorderRadius.circular(4),
                      child: Image.network(
                        "${ApiConfig.baseUrl}/app/gateway/download/file_object_data_thumbnail-large/${widget.model.fileObjectId}?token=${mainTop.userToken}",
                        fit: BoxFit.cover,
                      ))
                  : ClipRRect(
                      borderRadius: BorderRadius.circular(4),
                      child: CustomVideoPlayer(
                          customVideoPlayerController:
                              _customVideoPlayerController!),
                    ),
              showText
                  ? Container(
                      padding: const EdgeInsets.only(left: 8, right: 8),
                      width: width,
                      height: width / _controller!.value.aspectRatio,
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          const Text("title"),
                          //const Expanded(child: SizedBox()),
                          Text(Get.locale!.languageCode == "zh"
                              ? widget.model.name ?? ""
                              : widget.model.ename ?? ""),
                          const SizedBox(
                            height: 5,
                          ),
                        ],
                      ),
                    )
                  : const SizedBox.shrink(),
            ],
          ),
          onTap: () {
            debugPrint(
                "${ApiConfig.baseUrl}/app/gateway/download/file_object_data/${widget.model.fileObjectId}?token=${mainTop.userToken}");
          },
          onHover: (isHovering) {
            if (isHovering) {
              if (_controller == null) {
                _initializeVideoPlayer();
              }
              setState(() {
                showText = true;
              });
              _controller!.play();
            } else {
              setState(() {
                showText = false;
              });
              _controller?.pause();
              _controller?.dispose();
              _controller = null;
              _customVideoPlayerController = null;
            }
          },
          // onHover: (isHovering) {
          //   if (isHovering) {
          //     setState(() {
          //       showText = true;
          //     });
          //     _controller.play();
          //   } else {
          //     setState(() {
          //       showText = false;
          //     });
          //     _controller.pause();
          //   }
          // },
        );
      },
    );
  }
}

问题根源分析

  1. 切换逻辑写反:原代码判断_controller == null || _controller!.value.isInitialized时显示图片,这意味着视频初始化完成后反而继续显示图片,直到控制器销毁才显示视频——完全搞反了,导致初始化完成后突然切换,引发闪烁。
  2. 布局尺寸不稳定:图片和视频的容器尺寸没有强制统一,视频依赖控制器的aspectRatio,初始化前这个值不可用,导致切换时布局高度变化,InkWell的hover区域跟着变,触发反复hover。
  3. 资源销毁太激进:悬停离开时直接销毁控制器并置空,此时如果视频还在初始化异步操作中,会导致状态混乱;而且频繁创建销毁控制器不仅性能差,还会引发布局跳动。
  4. 文本层依赖未初始化的控制器:显示文本时用width / _controller!.value.aspectRatio,虽然逻辑上showText为true时控制器存在,但风险极高,且这个高度和图片高度不匹配,进一步加剧布局偏移。

解决方案与修改后的代码

核心思路

  • 固定图片和视频的宽高比,确保布局完全一致,稳定hover区域
  • 修正视频/图片的切换逻辑,只有视频初始化完成才显示视频
  • 优化控制器生命周期:悬停离开时只暂停,不立即销毁(可加延迟销毁逻辑平衡内存和性能)
  • 优化文本层,避免布局偏移,提升可读性

修改后的代码

class ToolsWorksItemView extends StatefulWidget {
  final WorksData model;
  const ToolsWorksItemView({Key? key, required this.model}) : super(key: key);

  @override
  State<ToolsWorksItemView> createState() => _ToolsWorksItemViewState();
}

class _ToolsWorksItemViewState extends State<ToolsWorksItemView> {
  final MainTopController mainTop = Get.find();
  VideoPlayerController? _controller;
  CustomVideoPlayerController? _customVideoPlayerController;
  bool showText = false;
  // 用缩略图/视频的实际比例替换,比如从model获取或预设16:9
  final double _fixedAspectRatio = 16 / 9; 

  void _initializeVideoPlayer() {
    if (_controller != null) return; // 避免重复初始化
    _controller = VideoPlayerController.networkUrl(Uri.parse(
        "${ApiConfig.baseUrl}/app/gateway/download/file_object_data/${widget.model.fileObjectId}?token=${mainTop.userToken}"))
      ..initialize().then((_) {
        if (mounted) setState(() {});
        _controller?.play();
      });
    _controller?.setLooping(true);
    _customVideoPlayerController = CustomVideoPlayerController(
      context: context,
      videoPlayerController: _controller!,
      customVideoPlayerSettings: const CustomVideoPlayerSettings(
        playbackSpeedButtonAvailable: false,
        settingsButtonAvailable: false,
        showDurationPlayed: false,
        controlBarAvailable: false,
        playOnlyOnce: false,
        showFullscreenButton: false,
      ),
    );
  }

  @override
  void dispose() {
    _controller?.dispose();
    _customVideoPlayerController?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final width = constraints.maxWidth;
        final height = width / _fixedAspectRatio;
        
        return InkWell(
          // 用SizedBox固定宽高,确保hover区域绝对稳定
          child: SizedBox(
            width: width,
            height: height,
            child: Stack(
              children: [
                // 正确切换:控制器存在且初始化完成时显示视频,否则显示图片
                if (_controller != null && _controller!.value.isInitialized)
                  ClipRRect(
                    borderRadius: BorderRadius.circular(4),
                    child: CustomVideoPlayer(
                      customVideoPlayerController: _customVideoPlayerController!,
                    ),
                  )
                else
                  ClipRRect(
                    borderRadius: BorderRadius.circular(4),
                    child: Image.network(
                      "${ApiConfig.baseUrl}/app/gateway/download/file_object_data_thumbnail-large/${widget.model.fileObjectId}?token=${mainTop.userToken}",
                      fit: BoxFit.cover,
                      width: width,
                      height: height,
                    ),
                  ),
                // 文本层用固定宽高,加半透明背景提升可读性
                if (showText)
                  Container(
                    width: width,
                    height: height,
                    padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
                    color: Colors.black.withOpacity(0.6),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      mainAxisAlignment: MainAxisAlignment.end,
                      children: [
                        const Text(
                          "title",
                          style: TextStyle(color: Colors.white, fontSize: 16),
                        ),
                        const SizedBox(height: 6),
                        Text(
                          Get.locale!.languageCode == "zh"
                              ? widget.model.name ?? ""
                              : widget.model.ename ?? "",
                          style: TextStyle(color: Colors.white.withOpacity(0.9), fontSize: 14),
                        ),
                      ],
                    ),
                  ),
              ],
            ),
          ),
          onTap: () {
            debugPrint(
                "${ApiConfig.baseUrl}/app/gateway/download/file_object_data/${widget.model.fileObjectId}?token=${mainTop.userToken}");
          },
          onHover: (isHovering) {
            setState(() {
              showText = isHovering;
            });
            
            if (isHovering) {
              _initializeVideoPlayer();
            } else {
              _controller?.pause();
              // 可选:延迟销毁控制器,避免频繁hover时反复创建
              // Future.delayed(const Duration(seconds: 3), () {
              //   if (!mounted || showText) return;
              //   _controller?.dispose();
              //   _controller = null;
              //   _customVideoPlayerController = null;
              // });
            }
          },
        );
      },
    );
  }
}

关键修改点说明

  1. 固定宽高比:定义_fixedAspectRatio,强制图片和视频容器尺寸一致,彻底解决布局跳动和hover反复触发的问题。
  2. 修正切换逻辑:用if-else清晰区分视频和图片的显示条件,避免闪烁。
  3. 控制器优化:
    • 初始化时加判断,防止重复创建
    • 悬停离开时只暂停,不立即销毁,提升性能;如果担心内存,可开启注释中的延迟销毁逻辑
    • 初始化回调中加mounted判断,避免组件已销毁时调用setState
  4. 文本层优化:用固定宽高,添加半透明背景,将文本放在底部,既避免布局偏移,又提升可读性。

内容的提问来源于stack exchange,提问作者Swift Allen

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.30 14:32:04