跳转至

05-Vexfy 实战

以 Vexfy 音乐播放器为实战案例,整合 Widget + GetX 状态管理,理解从点击到播放的完整数据流。


1. Vexfy 项目结构回顾

vexfy/app/lib/
├── main.dart                    # 入口
├── app/
│   ├── routes/                  # 路由配置
│   └── modules/
│       ├── player/
│       │   ├── player_page.dart # 全屏播放页面
│       │   ├── player_tab.dart   # Tab 1:播放列表
│       │   └── player.dart       # Tab 1 Controller
│       └── ...
├── services/
│   ├── player_service.dart      # 播放器核心服务(GetxService)
│   ├── local_music_service.dart # 本地音乐扫描服务
│   └── audio_handler_service.dart # 后台播放服务
├── data/
│   ├── models/
│   │   └── song_model.dart      # 歌曲数据模型
│   └── database/
│       ├── database_helper.dart # SQLite 数据库助手
│       └── tables.dart           # 表名常量
└── widgets/
    └── mini_player.dart         # 迷你播放器(底部悬浮栏)

核心关系: - PlayerService(GetxService)管理所有播放状态 - PlayerPage / MiniPlayer 通过 Obx 监听 PlayerService 的响应式变量 - LocalMusicService 扫描本地音乐,存入 SQLite - AudioHandlerService 包装 PlayerService,实现后台播放


2. PlayerService 源码逐行解析

源码路径:lib/services/player_service.dart

2.1 类定义与单例

/// 播放模式枚举
enum PlayMode {
  listLoop,    // 列表循环:播完最后一首回到第一首
  singleLoop,  // 单曲循环:重复当前歌曲
  shuffle,     // 随机播放
  sequential,  // 顺序播放:播完停止
}

/// 播放器服务 - 全局单例
/// 继承 GetxService,自动纳入 GetX 依赖注入容器
/// 相当于 Spring 的 @Service,单例生命周期
class PlayerService extends GetxService {
  // 手动实现单例(也可使用 GetxService 的 permanent 模式)
  PlayerService._();
  static final PlayerService instance = PlayerService._();

Java 对比:

@Service  // Spring Bean
public class PlayerService {
    private static final PlayerService INSTANCE = new PlayerService();
    public static PlayerService getInstance() { return INSTANCE; }
    private PlayerService() {}
}

2.2 响应式状态变量

  /// just_audio 的 AudioPlayer 实例
  /// late: 延迟初始化,onInit 时才赋值
  late final AudioPlayer _player;

  /// 播放队列(响应式列表)
  /// RxList<T> = 响应式的 List,任何增删改操作自动通知 Obx rebuild
  final RxList<SongModel> playlist = <SongModel>[].obs;

  /// 当前播放索引(-1 表示无歌曲)
  final RxInt currentIndex = (-1).obs;

  /// 当前播放歌曲(Rx<T?>,可为 null)
  final Rx<SongModel?> currentSong = Rx<SongModel?>(null);

  /// 播放状态
  final RxBool isPlaying = false.obs;
  final RxBool isLoading = false.obs;

  /// 播放进度(毫秒)
  final RxInt position = 0.obs;
  final RxInt duration = 0.obs;

  /// 播放模式
  final Rx<PlayMode> playMode = PlayMode.listLoop.obs;
  final RxBool isShuffle = false.obs;

Java 对比:AtomicInteger / AtomicBoolean + 手动观察者模式才能实现类似效果。

2.3 onInit — 初始化播放器

  @override
  void onInit() {
    super.onInit();  // 父类 GetxService.onInit 必须调用
    _initPlayer();
  }

  Future<void> _initPlayer() async {
    _player = AudioPlayer();  // 创建 just_audio 播放器

    // 监听播放状态(playing / processingState)
    // Stream 是 Dart 异步编程核心,类似 RxJava 的 Flowable
    _player.playerStateStream.listen((state) {
      isPlaying.value = state.playing;  // 是否正在播放
      isLoading.value = state.processingState == ProcessingState.loading ||
          state.processingState == ProcessingState.buffering;  // 是否加载中

      // 播放完毕自动切下一首
      if (state.processingState == ProcessingState.completed) {
        _onSongComplete();
      }
    });

    // 监听播放位置(每 200ms 左右更新一次)
    _player.positionStream.listen((pos) {
      position.value = pos.inMilliseconds;  // Duration → int(毫秒)
    });

    // 监听总时长(加载新歌曲时触发)
    _player.durationStream.listen((dur) {
      if (dur != null) {
        duration.value = dur.inMilliseconds;
      }
    });
  }

Java 对比: Java 没有内置的 Stream API,需要用 RxJava / Flow / LiveData 才能实现类似效果。

2.4 playSong — 播放指定歌曲

  /// 播放歌曲
  /// [song] 目标歌曲
  /// [playNow] true=立即播放,false=只加入队列不播
  Future<void> playSong(SongModel song, {bool playNow = true}) async {
    // 如果歌曲不在队列中,加入队列
    int idx = playlist.indexWhere((s) => s.id == song.id);
    if (idx < 0) {
      playlist.add(song);
      idx = playlist.length - 1;
    }

    // 切换到该歌曲
    await _switchToIndex(idx, playNow: playNow);
  }

  Future<void> _switchToIndex(int index, {bool playNow = true}) async {
    if (index < 0 || index >= playlist.length) return;

    // 更新当前歌曲信息
    currentIndex.value = index;
    currentSong.value = playlist[index];

    final song = playlist[index];
    final filePath = song.filePath;
    if (filePath == null) return;

    isLoading.value = true;
    try {
      // 加载音频文件路径(just_audio 支持本地路径 / URL)
      await _player.setFilePath(filePath);
      if (playNow) {
        await _player.play();  // 开始播放
      }
    } catch (e) {
      isLoading.value = false;
      rethrow;  // 错误上抛
    }
  }

Java 对比: 相当于 Spring 的 @Service 方法,不需要额外注解。

2.5 播放控制

  /// 播放/暂停切换
  Future<void> togglePlayPause() async {
    if (isPlaying.value) {
      await _player.pause();
    } else {
      await _player.play();
    }
  }

  /// 上一首(考虑 shuffle 模式)
  Future<void> previous() async {
    if (playlist.isEmpty) return;

    int prevIndex;
    if (isShuffle.value && _shuffledIndices != null) {
      // shuffle 模式:从 shuffle 列表中找上一首
      final currentShufflePos = _shuffledIndices!.indexOf(currentIndex.value);
      prevIndex = _shuffledIndices![
          (currentShufflePos - 1 + _shuffledIndices!.length) %
              _shuffledIndices!.length];
    } else {
      // 普通模式:列表循环(最后一首的上一首是第一首)
      prevIndex = (currentIndex.value - 1 + playlist.length) % playlist.length;
    }
    await _switchToIndex(prevIndex);
  }

  /// 下一首
  Future<void> next() async {
    if (playlist.isEmpty) return;
    int nextIndex;
    if (isShuffle.value && _shuffledIndices != null) {
      final currentShufflePos = _shuffledIndices!.indexOf(currentIndex.value);
      nextIndex = _shuffledIndices![
          (currentShufflePos + 1) % _shuffledIndices!.length];
    } else {
      nextIndex = (currentIndex.value + 1) % playlist.length;
    }
    await _switchToIndex(nextIndex);
  }

  /// 跳转到指定百分比
  Future<void> seekToPercent(double percent) async {
    final total = duration.value;
    if (total <= 0) return;
    final target = (total * percent).round();
    await seekTo(target);
  }

  /// 进度百分比(供 UI 使用)
  double get progressPercent {
    if (duration.value == 0) return 0;
    return position.value / duration.value;
  }

2.6 歌曲播放完成的处理

  /// 根据播放模式决定下一首行为
  void _onSongComplete() {
    switch (playMode.value) {
      case PlayMode.singleLoop:
        // 单曲循环:回到 0 位置重新播放
        _player.seek(Duration.zero);
        _player.play();
        break;
      case PlayMode.listLoop:
        // 列表循环:下一首
        next();
        break;
      case PlayMode.shuffle:
        // shuffle 模式:下一首
        next();
        break;
      case PlayMode.sequential:
        // 顺序播放:最后一首则停止
        if (currentIndex.value < playlist.length - 1) {
          next();
        } else {
          stop();
        }
        break;
    }
  }

3. LocalMusicService 源码解析

源码路径:lib/services/local_music_service.dart

3.1 扫描本地音乐

class LocalMusicService {
  // 单例
  LocalMusicService._();
  static final LocalMusicService instance = LocalMusicService._();

  // on_audio_query 插件:查询设备音频文件
  final oaq.OnAudioQuery _audioQuery = oaq.OnAudioQuery();
  final DatabaseHelper _dbHelper = DatabaseHelper.instance;

  /// 全量扫描本地音乐
  /// 返回扫描到的歌曲数量
  Future<int> scanAllMusic({
    void Function(int scanned, int total)? onProgress,  // 进度回调
  }) async {
    // 请求存储权限(Android 需要)
    final hasPermission = await requestPermission();
    if (!hasPermission) throw Exception('缺少存储权限');

    // 查询所有音频文件
    final songs = await _audioQuery.querySongs(...);

    int count = 0;
    final total = songs.length;

    for (final song in songs) {
      if (!_isSupportedFormat(uri)) continue;

      final songModel = _fromAudioSongModel(song);  // 转换模型
      await _upsertSong(songModel);  // 存入 SQLite
      count++;
      onProgress?.call(count, total);  // 回调进度
    }
    return count;
  }
}

Java 对比: 相当于 Spring 的 @Service 扫描文件系统,解析 MP3 元数据存入数据库。


4. PlayerPage 源码解析

源码路径:lib/app/modules/player/player_page.dart

4.1 整体结构

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

  @override
  Widget build(BuildContext context) {
    // 获取 PlayerService 实例(GetX 依赖注入)
    final playerService = Get.find<PlayerService>();

    return Scaffold(
      backgroundColor: Colors.white,
      body: Obx(() {
        // Obx 监听 PlayerService 的所有 .obs 变量
        // 任何响应式变量变化,build 方法重新执行
        final song = playerService.currentSong.value;
        final isPlaying = playerService.isPlaying.value;
        final isLoading = playerService.isLoading.value;

        return SafeArea(
          child: Column(
            children: [
              _buildTopBar(context),      // 顶部返回按钮
              Expanded(child: _buildCoverView(song)),  // 封面
              _buildSongInfo(song),       // 歌名歌手
              _buildProgressBar(playerService),  // 进度条
              _buildControlBar(playerService, isPlaying, isLoading), // 控制栏
            ],
          ),
        );
      }),
    );
  }
}

4.2 进度条 — Obx 嵌套

  Widget _buildProgressBar(PlayerService playerService) {
    return Obx(() {
      // Obx 监听 position 和 duration
      final pos = playerService.position.value;
      final dur = playerService.duration.value;
      final percent = dur > 0 ? pos / dur : 0.0;

      return SliderTheme(
        data: SliderThemeData(trackHeight: 4, thumbShape: RoundSliderThumbShape(enabledThumbRadius: 6)),
        child: Slider(
          value: percent.clamp(0.0, 1.0),
          onChanged: (value) {
            // 拖动进度条,跳转播放位置
            playerService.seekToPercent(value);
          },
        ),
      );
    });
  }

4.3 控制栏

  Widget _buildControlBar(PlayerService playerService, bool isPlaying, bool isLoading) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        // 随机播放按钮(响应式图标颜色)
        Obx(() => IconButton(
              icon: Icon(Icons.shuffle,
                color: playerService.isShuffle.value
                    ? AppTheme.primaryGreen  // 开启:绿色
                    : AppTheme.textSecondary),  // 关闭:灰色
              onPressed: () => playerService.toggleShuffle(),
            )),

        // 上一首
        IconButton(
          icon: const Icon(Icons.skip_previous, size: 44),
          onPressed: () => playerService.previous(),
        ),

        // 播放/暂停(加载中显示转圈)
        isLoading
            ? const SizedBox(width: 64, height: 64, child: CircularProgressIndicator(...))
            : IconButton(
                icon: Icon(
                  isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
                  size: 64, color: AppTheme.primaryGreen,
                ),
                onPressed: () => playerService.togglePlayPause(),
              ),

        // 下一首
        IconButton(
          icon: const Icon(Icons.skip_next, size: 44),
          onPressed: () => playerService.next(),
        ),

        // 循环模式(响应式)
        Obx(() => IconButton(
              icon: Icon(_getRepeatIcon(playerService.playMode.value),
                color: playerService.playMode.value != PlayMode.sequential
                    ? AppTheme.primaryGreen : AppTheme.textSecondary),
              onPressed: () => playerService.cycleRepeatMode(),
            )),
      ],
    );
  }

5. 完整流程:用户点击播放

用户点击歌曲
    │
    ▼
PlayerPage/PlayerTab 调用 playerService.playSong(song)
    │
    ▼
playSong() 内部:
  1. playlist.add(song)  →  RxList 响应式更新,UI 自动刷新队列
  2. _switchToIndex(idx) →  currentSong.value = song(响应式)
  3. _player.setFilePath(filePath)  →  just_audio 加载音频
  4. _player.play()  →  音频播放
    │
    ▼
just_audio 触发 playerStateStream / positionStream
    │
    ▼
PlayerService 监听回调:
  isPlaying.value = true
  position.value = 当前位置(毫秒)
  duration.value = 总时长(毫秒)
    │
    ▼
PlayerPage 的 Obx 监听到响应式变量变化
    │
    ▼
Obx 闭包重新执行:
  - Text(song.title) 更新歌名
  - Text(isPlaying ? '播放中' : '暂停') 更新状态
  - Slider 更新进度条
  - Icon 更新播放/暂停图标

下一步

06-网络请求Dio — 了解 Flutter 如何做网络请求