鸿蒙版 Flutter Video 视频播放组件:播放控制、全屏切换与倍速播放

本文代码均为完整可运行片段,新建 Flutter 工程后整段复制即可,无需额外依赖
运行载体:鸿蒙真机(Mate 60 / Pura 70),基于 OHOS 适配版 Flutter SDK
说明:演示工程用"模拟播放器"完整实现播放交互层(零插件可验证),真实片源接入路径见第 11 章

本文技术栈速览

项目 取值
Flutter SDK 3.27.5-ohos-1.0.1(OpenHarmony 适配版,非 Google 官方版)
运行设备 鸿蒙真机(Mate 60 / Pura 70),不支持 DevEco 模拟器
播放控制 Timer 驱动状态机 + Slider 进度拖拽
演示载体 视频播放器:播放/暂停、进度拖拽、全屏切换、倍速播放

一、引言:视频是媒体的"主菜"

在移动应用里,视频是唯一一个"用户愿意把时间整块交给你"的内容形态。图文信息是碎片阅读,用户随时可以打断;视频则要求用户坐下来、停下来、看完它——一集教程 10 分钟,一部宣传片 3 分钟,这段时间内应用占据的是用户全部的注意力。正因为视频的"时间独占"属性,视频播放器成为媒体类应用的核心阵地:长视频平台靠它留存,短视频平台靠它无限滑动,教育应用靠它传递知识,电商应用靠它展示商品。

播放器的工程质量直接决定用户的去留。一个劣质播放器(卡顿、操作反直觉、全屏切换失灵)会让用户在看第一分钟时就关掉应用;一个优秀的播放器则要让用户几乎感觉不到它的存在——播放器越"隐形",用户越沉浸。而"隐形"的背后,是播放/暂停、进度拖拽、全屏切换、倍速播放这些基础控制的丝滑顺畅。这些控制看似简单,实则是媒体工程的全部基本功:状态管理、时序控制、生命周期、系统 UI 交互。

这四个基本功分别对应一个具体的工程命题:状态管理回答"播放器在任何时刻处于什么状态"(播放中/暂停/拖拽中/完成),时序控制回答"进度怎么推进、何时停"(Timer 或解码器驱动),生命周期回答"离开页面时资源怎么释放"(不释放就泄漏),系统 UI 交互回答"全屏时系统栏怎么处置"(沉浸式模式)。四个命题没有一个是花架子——播放器的每个交互 bug,都能追溯到其中一个命题的失守。本文的模拟播放器把四个命题全部演练一遍,再用接口对齐表给出真实片源的替换路径。

ArkUI 原生提供 Video 组件(src / controls / autoPlay / loop / onPrepared / onStart / onPause / onFinish / controller 的 play/pause/seek),一套完整的播放控制开箱即用;Flutter 生态的对应物是 video_player 插件(官方维护,play/pause/seekTo/setPlaybackSpeed 等方法族)。但视频播放有一个特殊之处:它依赖平台媒体能力(ArkUI 走系统播放器,Flutter 走原生解码器),鸿蒙适配版上 video_player 需要 OHOS 平台实现配合。本文的演示工程因此采用"模拟播放器"方案——用纯 Dart 把播放交互层完整实现(状态机、进度、倍速、全屏),零插件、可验证、真机即跑;真实的片源接入路径(video_player + OHOS 适配)在第 11 章给出完整替换方案。

术语解释:播放状态机指播放器在 播放中/暂停/拖拽中/播放完成 等状态间的流转规则;倍速(Playback Speed)指以 1.0x/1.5x/2.0x 等倍数加速播放;沉浸式(Immersive)指隐藏系统状态栏与导航栏的全屏显示模式。


二、环境准备

环境与系列前文一致,要点速览:

组件 版本 / 说明
Flutter SDK 3.27.5-ohos-1.0.1(OpenHarmony 适配版)
Dart SDK 3.6.2(随适配版内置)
DevEco Studio 5.0 及以上(管理真机连接)
鸿蒙真机 Mate 60 / Pura 70,开启开发者模式与 USB 调试

三步开工:flutter create 生成工程 → USB 连接真机并确认设备在线 → flutter run -d <deviceId> 首构建。本文工程为纯 Dart 层实现,不涉及 ArkTS 原生插件,ohos/ 目录无需改动。

播放器演示的真机验证要点:一是全屏切换的沉浸式效果——SystemChrome.setEnabledSystemUIMode(immersiveSticky) 隐藏系统栏是 Flutter 内置能力,鸿蒙真机上应正常生效,退出全屏恢复 edgeToEdge;二是倍速的节奏感——演示页倍速影响的是模拟进度推进速度(2.0x 时进度条明显快一倍),真实片源接入后由解码器控制,交互层逻辑不变;三是横竖屏——本文全屏保持竖屏 + 沉浸式(不强制横屏,避免依赖旋转能力差异),截图时注意全屏状态栏是否隐藏干净。

演示片源说明:模拟播放器的"画面区"用深色背景 + 播放图标代替真实视频帧——真机演示时的视觉重点在控制交互(播放/暂停/拖拽/全屏/倍速),画面内容不是本文目标;接入真实片源后画面区替换为 video_player 的纹理输出即可,交互层逻辑完全复用。


三、鸿蒙版 Flutter 与官方 Flutter 的差异对比

视频播放是"组件差异 + 能力差异"双重叠加的领域:

对比维度 ArkUI 原生 Flutter(OHOS 适配版)
组件来源 Video 内置组件 无内置,官方插件 video_player(需 OHOS 平台实现)
播放能力 系统播放器(MediaKit) 平台解码器(ExoPlayer 类)
控制接口 controller.play/pause/seek controller.play/pause/seekTo
进度事件 onPrepared/onStart/onPause/onFinish 回调监听 + 轮询 position
全屏 系统级全屏模式 SystemChrome 沉浸式 + 自建全屏页
倍速 原生支持 setPlaybackSpeed 方法族
零插件可行性 组件内置 交互层可零插件,解码层必须插件

两点重点说明:

  1. 解码层是插件分水岭:视频播放的"交互层"(按钮、进度条、倍速弹层、全屏页)纯 Dart 就能完成,本文演示工程就是这么做的;但"解码层"(把视频文件解成画面)依赖平台媒体能力——Flutter 官方用 video_player 插件、鸿蒙适配版需要 OHOS 平台实现。这与二维码一文的结论同源:纯计算能做的用社区包或自写,系统能力必须走插件——视频解码属于后者,但交互层先行用模拟数据验证,是零插件原则下最务实的做法;
  2. 进度事件模型的差异:ArkUI 的 Video 有 onPrepared/onStart/onPause/onFinish 四个生命周期事件;Flutter 的 video_player 用"监听器 + 轮询 position"模型——没有 onPause 事件,暂停与否需要自己记录状态。本文的模拟播放器把"状态必须自己管"这个 Flutter 特性提前演练了:_playing_loop_rate_position 四个状态字段就是 video_player 接入后依然要维护的那套状态。

除此之外,SystemChrome 的沉浸式模式、Navigator 的全屏页路由、Timer 的驱动机制在鸿蒙适配版上与官方一致,无系统级差异。

再补一条工程差异:控制栏的"形态差异"。ArkUI 的 Video 组件自带 controls 控制栏(播放/暂停/进度/时间一体),Flutter 没有内置控制栏——这既是差异也是自由:内置控制栏不可定制(样式、布局、功能都是固定的),自建控制栏则完全可控(本文的控制栏就是自建的,播放/暂停、进度、循环、全屏按需排布)。对播放器体验有要求的应用,自建控制栏是必经之路,本文的演示正好把这个"必经之路"完整走了一遍。


四、核心 API 解析:ArkUI Video 的 Flutter 对应

4.1 参数与方法对照总表

ArkUI Video Flutter 对应 说明
src controller(video_player 数据源) 片源来源
controls 自建控制栏 Flutter 无内置控制栏,需要自建
autoPlay controller.play() 初始化后调用 自动播放
loop controller.setLooping(true) 循环播放(本文 _loop 状态)
controller.play() controller.play() 播放
controller.pause() controller.pause() 暂停
controller.seek(位置) controller.seekTo(Duration) 进度跳转
onPrepared controller.initialize() 完成 准备完成
onStart 状态记录 + 监听 开始播放
onPause 状态记录 + 监听 暂停(Flutter 需自己记)
onFinish 监听 + position 判断 播放完成
倍速 controller.setPlaybackSpeed(1.5) 倍速播放

4.2 播放器状态机:四个状态字段

播放器的核心不是控件,而是状态机。本文用四个字段定义播放状态:

Duration _position = Duration.zero; // 当前进度
bool _playing = false;              // 是否播放中
bool _loop = false;                 // 是否循环
double _rate = 1.0;                 // 倍速

四个字段的组合构成播放器的全部状态。状态流转规则用一张状态图表达:

初始化

play

pause

play

按下进度条

松手

进度到末尾(非循环)

点击重播(回到起点)

重置

待播放

播放中

暂停

拖拽中

完成

状态机图里有两条值得注意的边:播放中 --> 拖拽中(拖动进度条时暂停推进,松手继续——本文 _dragging 字段的作用点)与 完成 --> 播放中(播放完成后点击重新从头播放,_finished && !_loop 时的重播逻辑)。状态机设计的验收标准:任何操作在任何状态下都有确定结果——这正是"播放器隐形"的技术底子。四字段的状态空间只有 24=162^4 = 1624=16 种组合,但覆盖了播放器的全部行为——状态机的好处就是穷举可控:状态数量有限,行为定义完整,没有"没定义的状态"。

4.3 进度推进:Timer 驱动的模拟与真实解码

本文模拟播放器的进度推进用 Timer 驱动:

// 每 500ms 推进一次,推进量 = 0.5 秒 × 倍速
_ticker = Timer.periodic(const Duration(milliseconds: 500), (_) {
  if (_dragging) return; // 拖动进度时暂停推进
  setState(() {
    final step = (500 * _rate).round();
    _position += Duration(milliseconds: step);
    ...
  });
});

两个细节体现了"模拟必须忠实于真实"的设计:一是倍速直接影响推进量500ms × _rate),与真实播放器的 setPlaybackSpeed 语义一致;二是拖动时暂停推进_dragging 检查),避免"手指拖着进度条,进度还在自己跑"的错位感——真实播放器同样需要在拖动期间暂停 position 更新。真实片源接入后,这段 Timer 逻辑整体替换为"从 video_player 轮询 position",但状态机与交互层一行不改——模拟层与真实层的接口对齐,是演示工程最重要的工程决策。

4.5 播放器的生命周期纪律

播放器的生命周期与其他组件不同:它不是"页面在就在",而是"页面在 + 播放中 = 资源活着"。两个纪律:

  1. dispose 必释放:Timer、解码器、监听器都必须在 dispose 里释放——离开页面不释放,播放器在后台继续推进进度、继续解码,内存与电量双泄漏;
  2. 状态与资源解耦_playing 是状态、_ticker 是资源,播放暂停切状态、dispose 切资源——状态与资源的生命周期分开管理,离开页面时状态可以丢(页面都没了),资源必须放(定时器还在跑就是事故)。

这两条纪律在模拟层是 Timer 的取消,在真实层是解码器的 dispose,本质相同:资源必须随页面生命周期结束

4.4 进度拖拽:onChanged 与 onChangeEnd

进度条的三段式处理:onChanged 期间更新位置并标记 _draggingonChangeEnd 清除标记。拖动中的"预演位置"与松手后的"确定位置"语义分离——拖动中显示的只是预览,松手才最终生效(真实播放器拖到中途松开前不会真的 seek)。Slider 的值由 _progress(位置/时长)受控,位置一变进度条跟着动,受控模型与 Select 一文的结论一脉相承。


五、全屏切换与沉浸式体验

全屏是视频播放器最核心的"形态切换",包含两层:UI 形态(全屏页面)与系统形态(隐藏系统栏)。

5.1 SystemChrome:系统 UI 的开关

Flutter 用 SystemChrome.setEnabledSystemUIMode 控制系统 UI 的显隐,两个模式对应全屏的进出:

模式 效果 使用时机
immersiveSticky 隐藏状态栏 + 导航栏,滑动边缘短暂唤出 进入全屏
edgeToEdge 恢复到正常系统栏 退出全屏

时序要求:先进沉浸式,再推全屏页;全屏页关闭后,再恢复系统栏。次序反了会出现"系统栏残留"或"页面切换时闪一下系统栏"的瑕疵。退出全屏的恢复放在 await push 之后,保证全屏页完全关闭才恢复。

5.2 全屏页与主页的状态共享

全屏播放时,播放状态(进度、播放中、倍速)必须与主页保持同步——用户在全屏里暂停,回到主页不应继续播。本文用"回调 + 状态上报"实现双页同步:全屏页持有进度快照,每次操作通过 onTogglePlay / onSeek 回调把状态传回主页,主页 setState 落回。主页是状态的唯一事实来源,全屏页只是它的"视图"——这个模型保证从全屏返回后界面状态与用户操作完全一致。

5.3 全屏的生命周期纪律

全屏切换有两个生命周期陷阱,本文的写法规避了它们:

  1. 进入全屏前停掉主页定时器_stopTicker())——否则主页与全屏页各有一个 Timer 推进进度,位置会"打架";
  2. 退出全屏后由回调重建状态onExit 触发主页 setState)——全屏页销毁前最后一次状态上报,保证主页与全屏页最终一致。

一句话纪律:同一时刻只允许一个进度推进者。主页计时器、全屏页交互,两者只能活一个。

5.4 全屏的交互规范

全屏模式是"专注观看"的形态,交互规范三条:

  1. 关闭入口永远可见:全屏页顶部常驻关闭按钮——用户随时能退出全屏,"困在全屏里"是视频体验的死刑;
  2. 信息最小化:全屏页只保留"进度 + 控制 + 片名"三样——全屏不是为了展示更多控件,而是为了展示更少干扰;
  3. 状态可视化:倍速标识、循环状态在全屏页保留展示(右上角速率标识)——用户在全屏里也能确认"当前在几倍速"。

三条规范的共同点:全屏是"少即是多"的形态,任何多余元素都在稀释沉浸感——但"退出路径"与"状态确认"这两样是底线,再少也不能少它们。全屏的退出还有一条细节:全屏页用 fullscreenDialog: true 的路由推入,返回手势与关闭按钮双通道可用——用户从哪条路进,就有对应的出,全屏是形态切换而不是囚笼。


六、倍速播放与循环播放

6.1 倍速的语义:推进量 × rate

倍速在真实播放器里由解码器控制(1.5x 时解码器每秒解码 1.5 秒的画面),在本文模拟器里由推进量控制——两者在交互层的语义一致:位置变化速率 × rate。倍速选择的 UI 是底部弹层(showModalBottomSheet),三档选项 1.0x / 1.5x / 2.0x,当前档位用勾选图标标识:

档位 推进速率 适用
1.0x 正常 默认、内容理解类
1.5x 1.5 倍 教程略过铺垫、长视频
2.0x 2 倍 赶进度、复习型观看

倍速选择的交互设计两个细节:当前档位在 AppBar 常驻显示(“1.0x"文字按钮,点击弹层),选完弹层自动关闭——用户随时知道当前倍速,无需进入弹层查看;倍速切换不打断播放状态(播放中切倍速,播放继续只是变快)——切换操作"零打断”,是倍速交互的体验底线。

6.2 循环播放:loop 的边界

循环播放的语义是"播完自动从头"——适合背景音乐、演示循环、健身跟练。两个工程细节:循环开关用 Switch 常驻控制栏(_loop 状态);播放完成的分支逻辑必须区分循环与非循环——_loop 为真时位置归零继续播,为假时才进入"完成态"(暂停 + 重播图标)。循环与非循环是两个不同的"完成行为",状态机里必须有明确分支,否则"播完卡住"或"播完停不下"二选一。

6.3 时间显示与格式化

播放器的时间显示(当前时间 / 总时长)看着不起眼,却是每个播放器都有的细节工程。本文的 _fmt 把 Duration 格式化成 MM:SS 格式:分钟补零、秒补零——01:23 而非 1:23。两个细节:

  1. 补零是必须的01:231:23 在视觉上差一个字符宽度,播放器时间每几百毫秒变一次,宽度抖动会让界面"跳动"——固定两位补零,时间数字永远等宽;
  2. 超一小时的处理:时长超过 60 分钟时 _fmt 会自然显示 61:23(分钟不折小时)——真实视频应用通常会升级为 HH:MM:SS 格式,演示片源 3:20 用 MM:SS 足够,格式化的扩展点是"按最大时长选格式",与视频加载"按量级选策略"的思维一致。

七、完整代码实现:视频播放器

本文代码全部内嵌,先给依赖配置,再给完整入口代码,最后分模块讲解。

7.1 pubspec.yaml

name: video_player_demo
description: "视频播放器:Flutter 鸿蒙版(OHOS)Video 视频播放组件实战配套工程"
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ^3.6.2

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

7.2 完整入口代码

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const VideoPlayerApp());
}

/// 视频播放器:播放控制、全屏切换、倍速播放(进度为模拟驱动)
class VideoPlayerApp extends StatelessWidget {
  const VideoPlayerApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '视频播放器',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0A59F7)),
        useMaterial3: true,
      ),
      home: const PlayerPage(),
    );
  }
}

/// 模拟片源(真实项目替换为 video_player 的数据源)
class _MockVideo {
  static const title = '鸿蒙适配版 Flutter 演示片源';
  static const duration = Duration(minutes: 3, seconds: 20); // 3:20
}

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

  
  State<PlayerPage> createState() => _PlayerPageState();
}

class _PlayerPageState extends State<PlayerPage> {
  Timer? _ticker;
  Duration _position = Duration.zero;
  bool _playing = false;
  bool _loop = false;
  double _rate = 1.0; // 倍速 1.0 / 1.5 / 2.0
  bool _dragging = false;

  Duration get _duration => _MockVideo.duration;

  double get _progress =>
      _duration.inMilliseconds == 0
          ? 0
          : _position.inMilliseconds / _duration.inMilliseconds;

  bool get _finished => _position >= _duration;

  
  void dispose() {
    _ticker?.cancel();
    super.dispose();
  }

  // 播放/暂停:启动或停止进度推进定时器
  void _togglePlay() {
    setState(() {
      if (_finished && !_loop) {
        _position = Duration.zero; // 播放完成后重新点播放 → 从头开始
      }
      _playing = !_playing;
    });
    if (_playing) {
      _startTicker();
    } else {
      _stopTicker();
    }
  }

  void _startTicker() {
    _ticker?.cancel();
    // 每 500ms 推进一次,推进量 = 0.5 秒 × 倍速
    _ticker = Timer.periodic(const Duration(milliseconds: 500), (_) {
      if (_dragging) return; // 拖动进度时暂停推进
      setState(() {
        final step = (500 * _rate).round();
        _position += Duration(milliseconds: step);
        if (_position >= _duration) {
          if (_loop) {
            _position = Duration.zero; // 循环播放
          } else {
            _position = _duration; // 播放完成
            _playing = false;
            _stopTicker();
          }
        }
      });
    });
  }

  void _stopTicker() {
    _ticker?.cancel();
    _ticker = null;
  }

  // 进度拖拽:拖动中暂停推进,松手后按新位置继续
  void _onSeekChanged(double value) {
    setState(() {
      _dragging = true;
      _position = Duration(
        milliseconds: (value * _duration.inMilliseconds).round(),
      );
    });
  }

  void _onSeekEnd(double value) {
    setState(() => _dragging = false);
  }

  // 倍速选择弹层
  Future<void> _showRateSheet() async {
    final result = await showModalBottomSheet<double>(
      context: context,
      showDragHandle: true,
      builder: (ctx) => SafeArea(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            for (final r in [1.0, 1.5, 2.0])
              ListTile(
                leading: Icon(
                  _rate == r
                      ? Icons.check_circle
                      : Icons.radio_button_unchecked,
                  color: _rate == r
                      ? Theme.of(ctx).colorScheme.primary
                      : null,
                ),
                title: Text('${r}x 倍速'),
                onTap: () => Navigator.of(ctx).pop(r),
              ),
          ],
        ),
      ),
    );
    if (result == null || !mounted) return;
    setState(() => _rate = result);
  }

  // 全屏切换:沉浸式模式推入全屏播放页
  Future<void> _enterFullscreen() async {
    await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
    if (!mounted) return;
    final playing = _playing;
    _stopTicker();
    await Navigator.of(context).push(
      MaterialPageRoute(
        fullscreenDialog: true,
        builder: (_) => _FullscreenPlayer(
          position: _position,
          playing: playing,
          rate: _rate,
          loop: _loop,
          onTogglePlay: (p) {
            setState(() {
              _position = p;
              _playing = !_playing;
            });
            _playing ? _startTicker() : _stopTicker();
          },
          onSeek: (p) {
            setState(() => _position = p);
          },
          onExit: () {
            setState(() {});
          },
        ),
      ),
    );
    await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
  }

  String _fmt(Duration d) {
    final m = d.inMinutes.toString().padLeft(2, '0');
    final s = (d.inSeconds % 60).toString().padLeft(2, '0');
    return '$m:$s';
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      appBar: AppBar(
        backgroundColor: Colors.black,
        foregroundColor: Colors.white,
        title: const Text(_MockVideo.title),
        actions: [
          TextButton(
            onPressed: _showRateSheet,
            child: Text(
              '${_rate}x',
              style: const TextStyle(
                color: Colors.white,
                fontSize: 16,
                fontWeight: FontWeight.w600,
              ),
            ),
          ),
        ],
      ),
      body: Column(
        children: [
          // 视频画面区(模拟)
          Expanded(
            child: GestureDetector(
              onTap: _togglePlay,
              child: Container(
                color: const Color(0xFF121212),
                child: Center(
                  child: _finished && !_loop
                      ? const Column(
                          mainAxisSize: MainAxisSize.min,
                          children: [
                            Icon(Icons.replay_circle_filled,
                                size: 64, color: Colors.white),
                            SizedBox(height: 8),
                            Text('播放完成,点击重播',
                                style: TextStyle(color: Colors.white70)),
                          ],
                        )
                      : Icon(
                          _playing
                              ? Icons.pause_circle_filled
                              : Icons.play_circle_filled,
                          size: 64,
                          color: Colors.white,
                        ),
                ),
              ),
            ),
          ),
          // 控制栏
          Container(
            color: const Color(0xFF1E1E1E),
            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
            child: Column(
              children: [
                Row(
                  children: [
                    Text(_fmt(_position),
                        style: const TextStyle(color: Colors.white)),
                    Expanded(
                      child: SliderTheme(
                        data: SliderTheme.of(context).copyWith(
                          trackHeight: 3,
                          thumbShape: const RoundSliderThumbShape(
                              enabledThumbRadius: 6),
                        ),
                        child: Slider(
                          value: _progress.clamp(0, 1),
                          onChanged: _onSeekChanged,
                          onChangeEnd: _onSeekEnd,
                        ),
                      ),
                    ),
                    Text(_fmt(_duration),
                        style: const TextStyle(color: Colors.white70)),
                  ],
                ),
                Row(
                  children: [
                    IconButton(
                      icon: Icon(
                        _playing ? Icons.pause : Icons.play_arrow,
                        color: Colors.white,
                        size: 32,
                      ),
                      onPressed: _togglePlay,
                    ),
                    const SizedBox(width: 8),
                    const Text('循环播放',
                        style: TextStyle(color: Colors.white70)),
                    Switch(
                      value: _loop,
                      onChanged: (v) => setState(() => _loop = v),
                    ),
                    const Spacer(),
                    IconButton(
                      icon: const Icon(Icons.fullscreen,
                          color: Colors.white, size: 28),
                      onPressed: _enterFullscreen,
                    ),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

/// 全屏播放页:共享播放状态,沉浸式全屏
class _FullscreenPlayer extends StatefulWidget {
  final Duration position;
  final bool playing;
  final double rate;
  final bool loop;
  final ValueChanged<Duration> onTogglePlay;
  final ValueChanged<Duration> onSeek;
  final VoidCallback onExit;

  const _FullscreenPlayer({
    required this.position,
    required this.playing,
    required this.rate,
    required this.loop,
    required this.onTogglePlay,
    required this.onSeek,
    required this.onExit,
  });

  
  State<_FullscreenPlayer> createState() => _FullscreenPlayerState();
}

class _FullscreenPlayerState extends State<_FullscreenPlayer> {
  late Duration _position;
  late bool _playing;

  
  void initState() {
    super.initState();
    _position = widget.position;
    _playing = widget.playing;
  }

  void _toggle() {
    setState(() {
      if (_position >= _MockVideo.duration && !widget.loop) {
        _position = Duration.zero;
      }
      _playing = !_playing;
    });
    widget.onTogglePlay(_position);
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: SafeArea(
        child: Column(
          children: [
            Row(
              children: [
                IconButton(
                  icon: const Icon(Icons.close, color: Colors.white),
                  onPressed: () {
                    widget.onExit();
                    Navigator.of(context).pop();
                  },
                ),
                const Expanded(
                  child: Text(
                    _MockVideo.title,
                    style: TextStyle(color: Colors.white),
                    overflow: TextOverflow.ellipsis,
                  ),
                ),
                Text('${widget.rate}x',
                    style: const TextStyle(
                        color: Colors.white70, fontSize: 14)),
              ],
            ),
            Expanded(
              child: GestureDetector(
                onTap: _toggle,
                child: Center(
                  child: Icon(
                    _playing ? Icons.pause_circle_filled : Icons.play_circle_filled,
                    size: 80,
                    color: Colors.white,
                  ),
                ),
              ),
            ),
            Row(
              children: [
                Text(_fmt(_position),
                    style: const TextStyle(color: Colors.white)),
                Expanded(
                  child: Slider(
                    value: (_position.inMilliseconds /
                            _MockVideo.duration.inMilliseconds)
                        .clamp(0, 1),
                    onChanged: (v) {
                      setState(() {
                        _position = Duration(
                          milliseconds:
                              (v * _MockVideo.duration.inMilliseconds)
                                  .round(),
                        );
                      });
                      widget.onSeek(_position);
                    },
                  ),
                ),
                Text(_fmt(_MockVideo.duration),
                    style: const TextStyle(color: Colors.white70)),
              ],
            ),
          ],
        ),
      ),
    );
  }

  String _fmt(Duration d) {
    final m = d.inMinutes.toString().padLeft(2, '0');
    final s = (d.inSeconds % 60).toString().padLeft(2, '0');
    return '$m:$s';
  }
}

7.3 分模块讲解

播放状态机_playing + _ticker 是"状态"与"驱动器"的组合——状态决定显示(播放/暂停图标),驱动器决定推进(Timer 是否运行)。播放/暂停切换同时更新两者,dispose 里取消定时器(生命周期安全,与 Stepper 倒计时的纪律一致)。

进度拖拽onChanged 预演位置 + _dragging 标记,onChangeEnd 松手收尾;拖动期间 ticker 的 _dragging 检查暂停推进——拖拽与播放互不打架。

倍速弹层:三档倍速 + 当前档勾选,选中即生效;AppBar 常驻显示当前倍速,随时可改,切换不打断播放。

全屏切换:进全屏先设 immersiveSticky、停主页 ticker,全屏页通过回调上报状态,退出恢复 edgeToEdge 并同步主页。主页是唯一事实来源,全屏页是视图。

循环播放:Switch 控制 _loop,播放完成分支判断循环(归零继续)与非循环(进入完成态)。


八、真机运行与效果展示

8.1 运行步骤

  1. USB 连接鸿蒙真机,DevEco Studio 设备列表确认在线(图 1);
  2. flutter run -d <deviceId> 首构建,hvigor 编译原生层;
  3. 真机呈现播放器主页(图 2);
  4. 按演示脚本逐项操作(图 3~图 6);
  5. 终端确认编译日志无 error(图 7)。

8.2 截图占位

截图占位共 7 张,覆盖播放器核心交互:

图 1:DevEco Studio 设备列表(鸿蒙真机在线)

在这里插入图片描述

*图 1 说明:设备列表中 Mate 60 状态为 Online*

图 2:播放界面

在这里插入图片描述

*图 2 说明:黑色画面区 + 播放图标,底部控制栏含进度条、时间、循环开关与全屏按钮,右上角 1.0x 倍速入口*

图 3:暂停状态
在这里插入图片描述

*图 3 说明:点击画面暂停,图标切换为播放,进度条停在当前进度,时间显示停在暂停点*

图 4:全屏模式

在这里插入图片描述

*图 4 说明:沉浸式全屏,状态栏与导航栏隐藏,画面居中放大,顶部有关闭按钮与倍速标识*
### 8.3 演示脚本
步骤 操作 预期结果
1 点播放按钮 图标切暂停,进度条开始推进(图 2)
2 点画面暂停 图标切播放,进度停止(图 3)
3 拖动进度条到中间 位置跳转,时间显示更新,松手继续推进
4 打开循环开关再播 播到末尾自动从头,不进入完成态
5 关闭循环,拖到末尾 进入完成态,显示"播放完成,点击重播"
6 点右上角 1.0x → 选 2.0x 进度条推进速度加倍
7 点全屏按钮 进入沉浸式全屏,系统栏隐藏(图 4)
8 全屏内暂停/拖动 状态实时同步主页
9 点关闭退出全屏 系统栏恢复,主页状态与全屏一致
10 播放中切 1.5x 播放不中断,进度变快

8.4 播放器交互的验收要点

播放器的演示验收除了功能清单,还有三个体验维度的检查:

  1. 反馈即时性:点击播放/暂停/切倍速,界面反馈必须在 100ms 内出现——播放器交互"慢半拍"是用户流失的头号原因;
  2. 进度平滑性:播放推进时进度条与时间数字平滑更新,没有跳变或卡顿——跳变意味着状态不一致,卡顿意味着推进逻辑过重;
  3. 退出干净利落:退出全屏、退出页面后,界面完全恢复(系统栏回来、状态同步、无残留动画)——"离开不拖泥带水"是播放器专业感的底线。

三个维度分别对应用户的"手感、眼感、安心感"——播放器做得好不好,最终由这"三感"打分。


九、视频加载优化与内存管理

视频是内存与 CPU 的"大客户",两个维度的工程要点:

9.1 加载优化:按需加载与预加载

优化 手段 收益
懒加载 滚动到可视才初始化播放器 列表页、Feed 流
预加载 下一个视频提前初始化 顺序观看(剧集、课程)
低画质先行 首帧/标清先出,清晰度再升级 弱网环境
缓存策略 播放过的分段本地缓存 回看频繁的场景

一条原则:播放器实例是稀缺资源,不要与列表项一一绑定。Feed 流里为每条视频建一个播放器实例,内存会瞬间爆掉——标准做法是"单实例 + 复用":滚动到哪条,把唯一播放器绑到哪条。视频加载优化的核心纪律是"少建实例、延迟初始化、及时释放"。

9.2 内存管理:播放器的释放纪律

高危行为 后果 正确做法
离开页面不释放 解码器常驻,内存泄漏 dispose 里释放播放器
列表项持有实例 内存随列表膨胀 单实例复用
全屏/主页双实例 双解码双内存 同一时刻单实例(本文纪律)
循环播放不释放 后台持续解码耗电 离开即停

真实接入 video_player 后,内存纪律与本文的 Timer 纪律同构:dispose 释放、单实例、同一时刻一个推进者——模拟层演练的纪律,在真实解码层原样生效。

9.3 加载与播放的体验指标

视频体验的优化是否到位,用三个指标衡量:

指标 含义 达标线
首帧时间 从点击到第一帧画面 弱网 < 3s,5G < 1s
卡顿率 播放中卡顿次数/时长 每 10 分钟 < 1 次
起播成功率 起播失败/总尝试 > 99%

三个指标的优化手段各不相同:首帧时间靠"低画质先行 + 预加载";卡顿率靠"网络自适应码率(ABR)+ 缓冲预取";起播成功率靠"错误重试 + 降级切换源"。指标的共性是用数据驱动优化——播放器不是"看起来流畅就行",而是"测出来达标才算数",DevTools 的帧率记录与真机实测是两条验证路径。

9.4 演示工程的性能侧结论

本文模拟播放器的性能风险集中在 Timer 驱动的 setState 频率:每 500ms 一次推进,页面整体 setState 一次——频率极低(2Hz),对帧率无影响。若真实播放器按帧级 position 刷新(30Hz 以上),每次 setState 只更新时间文本与进度条滑块,其余区域用 RepaintBoundary 隔离,避免整页重绘——性能的黄金法则:更新的频率越高,更新的范围要越小


十、真机调试踩坑指南

症状 根因 解法
全屏后系统栏还在 沉浸式模式未生效 先 setEnabledSystemUIMode 再推全屏页
退出全屏系统栏消失 恢复模式未执行 恢复写在 await push 之后
全屏返回后进度倒退 主页与全屏状态未同步 主页为唯一事实来源,回调上报
全屏与主页双倍速推进 两个 Timer 都在跑 进全屏前停主页 ticker
拖动进度条进度自己跑 拖动中未暂停推进 _dragging 检查
播放完成卡住 完成态分支缺失 _finished && !_loop 明确分支
切倍速后推进异常 推进量未乘 rate step = 500ms × rate
循环开关无效 完成分支没判断 loop 循环归零继续,非循环进完成态
黑屏上图标看不清 画面区与图标对比不足 图标白色 + 深色背景(本文配色)
真机日志找不到 Flutter 输出 日志走 hdc 而非 adb hdc shell hilog 过滤 flutter 关键字

10.1 一段典型的踩坑实录

初版的全屏切换踩了"进度倒退"的坑:全屏页里看了 1 分钟,退出全屏后主页进度回到了进入全屏前的值——用户在全屏里白看了。排查过程:主页在全屏期间只停了 Timer,但没有把全屏期间的状态变化同步回来;全屏页自己有一份 _position 副本,退出时主页取不到。修复就是第 5.2 节讲的"主页为唯一事实来源":全屏页每次操作(播放/暂停/拖动)都通过回调把最新位置上报主页,主页 setState 落回——退出全屏时主页状态与全屏页天然一致。这个坑的教训是:跨页面的状态,必须有一个唯一的真源,视图页只做展示与上报

10.2 播放器测试的自动化姿势

播放器状态机适合用 widget 测试锁定,四个高频用例:

  1. 播放暂停切换:点播放 → 断言图标切暂停、ticker 运行;点暂停 → 断言恢复;
  2. 进度推进:pump 1 秒 → 断言位置推进约 1 秒(倍速 1.0x);
  3. 倍速推进:设 2.0x → pump 1 秒 → 断言推进约 2 秒;
  4. 完成态:位置推进到末尾 → 断言进入完成态、ticker 停止。

四条用例恰好覆盖状态机的核心流转:切换、推进、倍速、完成。Timer 的测试要点是 tester.pump(duration) 推进假时钟,让"等待 500ms 的推进循环"在测试里可控——这是 Timer 驱动逻辑可测性的关键。

10.3 播放器验证的三条金线

播放器的真机验证,三条金线要逐一过:

  1. 状态一致性:播放/暂停/拖动/全屏/倍速五种操作交叉组合,界面上任何时刻的图标、进度、时间、倍速标识都必须与实际状态一致——"显示与状态脱节"是播放器最高频的隐性 bug;
  2. 互斥性:拖动进度条时进度不自己跑、全屏时主页没有第二根进度条在推进——"同一时刻只有一个推进者"的可视化验证;
  3. 生命周期:全屏进出一轮、页面推退一轮后,进度、状态、定时器都干净——"离开即释放"的可观测验证。

三条金线对应本文的三个核心机制:状态机、互斥纪律、生命周期。金线全过,播放器的工程闭环才算合上。


十一、真实片源接入:video_player 的替换路径

本文模拟播放器的价值在于"交互层先行验证",真实项目的片源接入需要 video_player(Flutter 官方插件,鸿蒙适配版需确认 OHOS 平台实现可用)。替换路径是接口对齐,不是重写:

模拟层 video_player 层
_MockVideo.duration controller.value.duration
Timer 推进 _position 轮询 controller.value.position
_togglePlay 启停 Timer controller.play() / pause()
_onSeekChanged_position controller.seekTo(Duration)
_rate 影响推进量 controller.setPlaybackSpeed(rate)
dispose 取消 Timer controller.dispose() 释放解码器

接入路径三步:初始化(VideoPlayerController.networkUrl(...) + initialize)→ 播放控制替换(play/pause/seekTo/setPlaybackSpeed 一一对应)→ 状态轮询(position 监听驱动 UI 进度条)。交互层(控制栏、倍速弹层、全屏页、状态机)全部复用,只换数据层——这正是"模拟先行"策略的红利:交互逻辑先在零插件环境验证,真实接入只做数据源替换。


十二、总结与扩展

视频播放器是媒体应用的"主菜",本文用纯 Dart 模拟播放器把播放交互层完整实现:四字段状态机(位置/播放/循环/倍速)、Timer 驱动推进、拖拽与推进互斥、沉浸式全屏双页同步、三档倍速弹层。三条核心纪律值得背下来:同一时刻只允许一个进度推进者、主页是状态的唯一事实来源、dispose 必须释放。模拟层与 video_player 的接口对齐关系已经列明,真实接入是"换数据层不换交互层"。

回看引言的问题:播放器怎么做到"隐形"?答案在本文的每个细节里——状态机让任何操作都有确定结果(不打架)、进度拖拽与推进互斥(不跳变)、全屏状态同步(不倒退)、倍速零打断(不别扭)。"隐形"不是没有交互,而是交互永远符合预期

模拟先行的策略值得作为方法论收进系列工具箱:当目标能力依赖平台(解码、相机、定位),先把"交互层 + 状态层"用模拟数据完整验证——接口对齐、体验可测、零插件可跑,真实能力接入时只换数据源。二维码一文的"类型识别模拟"、本文的"播放器模拟",都是这套方法论的实践。

从本文工程出发可以扩展的方向:

  1. 真实播放:接入 video_player(接口对齐表已备好),网络片源 + 加载进度 + 缓冲提示;
  2. 横屏全屏:SystemChrome 横屏模式 + 旋转监听,真横屏全屏播放;
  3. 播放列表:连续播放、自动切下一集、断点记忆(播放进度本地持久化);
  4. 控制栏自动隐藏:播放时控制栏几秒后淡出,点击唤出;
  5. 清晰度切换:多清晰度源 + 切换弹层,低画质先行策略;
  6. 画中画与后台播放:系统级画中画(依赖平台能力,需插件)与音频后台播放;
  7. 手势控制:双击暂停/播放、左右滑动快进快退、上下滑动调音量亮度——把"控制栏之外"的手势层补全,播放器的"隐形"程度再进一步。

最后用甘特图回顾播放器的开发节奏,延续本系列(Button → TextInput → Search → Grid → CustomDialog → Stepper → Select → QRCode → Video)的工程化节奏:

2026-09-27 2026-09-28 2026-09-29 2026-09-30 2026-10-01 2026-10-02 2026-10-03 2026-10-04 2026-10-05 2026-10-06 环境与真机联调 播放器状态机梳理 播放暂停与进度推进 进度拖拽与互斥 全屏切换与双页同步 倍速与循环播放 真机验证与截图采集 文章撰写与修订 准备 开发 验证 视频播放器开发计划

播放器的哲学是"把控制权交给用户,把复杂度留给自己"。用户只需要三个动作——点一下播、拖一下跳、滑一下调——剩下的一切(状态流转、时序管理、双页同步、资源释放)都是播放器自己的功课。做得好的播放器,用户感觉不到播放器的存在,只感觉到内容本身——这就是"隐形"的最高境界,也是本文每一行代码的目标。

最后用一句话给本系列做个小结的注脚:从 Button 到 Video,九篇文章走过的路,是"组件 → 交互 → 体系"的完整链路——单组件(Button/TextInput/Search/Grid)、交互容器(CustomDialog/Stepper/Select)、能力型组件(QRCode/Video)。视频播放器是这条链路的阶段性高点:它把所有前文的能力(状态管理、弹层、进度、生命周期)揉进了一个复杂交互体。理解了播放器的状态机与生命周期,移动开发里最复杂的交互形态也就有了模板——媒体、地图、编辑器,无一不是"状态机 + 生命周期"的变体。

Logo

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

更多推荐