Harmony Flutter 跨平台开发实战:鸿蒙与音乐律动艺术、贝塞尔流体律动:三阶贝塞尔曲线的“呼吸“感

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


🌊 一、贝塞尔曲线:从数学原理到视觉艺术

📚 1.1 贝塞尔曲线的历史渊源

贝塞尔曲线(Bézier Curve)是以法国工程师**皮埃尔·贝塞尔(Pierre Bézier)**的名字命名的。他在 1960 年代为雷诺汽车公司工作时,为了解决汽车车身设计的曲线问题而发明了这种曲线。最初,这种曲线被用于计算机辅助设计(CAD)系统,后来逐渐成为计算机图形学中最基础也是最重要的曲线类型之一。

贝塞尔曲线的魅力在于它的直观性可控性。设计师只需要调整几个控制点,就能得到各种优美的曲线形状。这种特性使得贝塞尔曲线在以下领域得到了广泛应用:

  • 🎨 字体设计:TrueType 和 PostScript 字体都使用贝塞尔曲线描述字符轮廓
  • 🖼️ 矢量图形:SVG、Adobe Illustrator 等矢量图形格式基于贝塞尔曲线
  • 🎬 动画路径:游戏和动画中的物体运动轨迹
  • 🚗 工业设计:汽车、飞机等产品的曲面设计

在现代 UI 设计中,几乎所有的曲线元素都离不开贝塞尔曲线的身影。


📐 1.2 贝塞尔曲线的数学定义

贝塞尔曲线是一种参数曲线,它通过一组控制点来定义曲线的形状。根据控制点的数量,贝塞尔曲线可以分为不同阶数:

阶数 控制点数 名称 特点
一阶 2 线性贝塞尔曲线 ➖ 直线段
二阶 3 二次贝塞尔曲线 📐 一个控制点,单弯曲线
三阶 4 三次贝塞尔曲线 🌊 两个控制点,双弯曲线
n阶 n+1 n次贝塞尔曲线 🔮 n个控制点
🔹 1.2.1 一阶贝塞尔曲线(线性插值)

一阶贝塞尔曲线是最简单的形式,实际上就是两点之间的直线段。其参数方程为:

复制代码
B(t) = (1-t) * P0 + t * P1,  t ∈ [0, 1]

其中:

  • 📍 P0 是起点
  • 📍 P1 是终点
  • ⏱️ t 是参数

当 t 从 0 变化到 1 时,点 B(t) 从 P0 移动到 P1,形成一条直线。

🔹 1.2.2 二阶贝塞尔曲线(二次贝塞尔曲线)

二阶贝塞尔曲线由三个点定义:起点 P0、控制点 P1 和终点 P2。其参数方程为:

复制代码
B(t) = (1-t)² * P0 + 2(1-t)t * P1 + t² * P2,  t ∈ [0, 1]

💡 理解技巧 :这个公式可以理解为:首先在 P0P1 和 P1P2 两条线段上进行线性插值,得到两个中间点,然后在这两个中间点之间再进行线性插值。这种递归的构造方式被称为德卡斯特里奥算法(De Casteljau's Algorithm)

🔹 1.2.3 三阶贝塞尔曲线(三次贝塞尔曲线)⭐

三阶贝塞尔曲线是我们本文的重点,它由四个点定义:

复制代码
B(t) = (1-t)³ * P0 + 3(1-t)²t * P1 + 3(1-t)t² * P2 + t³ * P3,  t ∈ [0, 1]
符号 含义 说明
P0 起点 🟢 曲线的起始位置
P1 控制点1 🔵 控制曲线的起始方向和弯曲程度
P2 控制点2 🔵 控制曲线的结束方向和弯曲程度
P3 终点 🔴 曲线的结束位置

三阶贝塞尔曲线具有以下重要特性

特性 说明
🎯 端点插值 曲线通过起点 P0 和终点 P3
📏 切线性质 曲线在起点处的切线方向为 P0P1,在终点处的切线方向为 P2P3
📦 凸包性质 曲线完全包含在控制点形成的凸包内
🔄 仿射不变性 对控制点进行仿射变换等价于对曲线进行相同的仿射变换

🎯 1.3 为什么选择三阶贝塞尔曲线?

在音乐可视化应用中,三阶贝塞尔曲线是最常用的选择,原因如下:

优势 详细说明
🌊 灵活性 三阶贝塞尔曲线可以表示 S 形曲线,这是二阶贝塞尔曲线无法做到的。S 形曲线在模拟流体运动时非常重要,可以创造出更加自然的"呼吸感"效果。
🎮 可控性 两个控制点提供了足够的自由度,设计师可以精确控制曲线的形状,同时保持曲线的平滑性。
计算效率 三阶贝塞尔曲线的计算复杂度适中,在保证视觉效果的同时不会对性能造成太大负担。
🔗 兼容性 大多数图形库和设计工具都原生支持三阶贝塞尔曲线,便于在不同平台间移植。

🔧 二、德卡斯特里奥算法:贝塞尔曲线的几何构造

🧮 2.1 算法原理

德卡斯特里奥算法是一种递归分割算法,用于计算贝塞尔曲线上的点。它的核心思想是将 n 阶贝塞尔曲线分解为两个 n-1 阶贝塞尔曲线。

对于三阶贝塞尔曲线,算法步骤如下:

复制代码
步骤 1️⃣:给定四个控制点 P0、P1、P2、P3 和参数 t
    ↓
步骤 2️⃣:在线段 P0P1 上找到点 Q0,使得 Q0 = (1-t)P0 + tP1
    ↓
步骤 3️⃣:在线段 P1P2 上找到点 Q1,使得 Q1 = (1-t)P1 + tP2
    ↓
步骤 4️⃣:在线段 P2P3 上找到点 Q2,使得 Q2 = (1-t)P2 + tP3
    ↓
步骤 5️⃣:在线段 Q0Q1 上找到点 R0,使得 R0 = (1-t)Q0 + tQ1
    ↓
步骤 6️⃣:在线段 Q1Q2 上找到点 R1,使得 R1 = (1-t)Q1 + tQ2
    ↓
步骤 7️⃣:在线段 R0R1 上找到点 B,使得 B = (1-t)R0 + tR1
    ↓
步骤 8️⃣:点 B 就是曲线上参数为 t 的点 ✅

🎨 可视化理解:这个过程就像是在不断地"切割"线段,每次切割都使点更接近曲线上的最终位置。


💻 2.2 Dart 实现德卡斯特里奥算法

dart 复制代码
import 'dart:math';

/// 二维点类
class Point2D {
  final double x;
  final double y;
  
  const Point2D(this.x, this.y);
  
  // 运算符重载
  Point2D operator +(Point2D other) => Point2D(x + other.x, y + other.y);
  Point2D operator -(Point2D other) => Point2D(x - other.x, y - other.y);
  Point2D operator *(double scalar) => Point2D(x * scalar, y * scalar);
  
  double get distance => sqrt(x * x + y * y);
  
  @override
  String toString() => 'Point2D($x, $y)';
}

/// 三阶贝塞尔曲线
class CubicBezierCurve {
  final Point2D p0;  // 🟢 起点
  final Point2D p1;  // 🔵 控制点1
  final Point2D p2;  // 🔵 控制点2
  final Point2D p3;  // 🔴 终点
  
  CubicBezierCurve(this.p0, this.p1, this.p2, this.p3);
  
  /// 使用德卡斯特里奥算法计算曲线上的点
  Point2D pointAt(double t) {
    // 第一层插值
    final q0 = _lerp(p0, p1, t);
    final q1 = _lerp(p1, p2, t);
    final q2 = _lerp(p2, p3, t);
    
    // 第二层插值
    final r0 = _lerp(q0, q1, t);
    final r1 = _lerp(q1, q2, t);
    
    // 第三层插值(最终点)
    return _lerp(r0, r1, t);
  }
  
  /// 线性插值
  Point2D _lerp(Point2D a, Point2D b, double t) {
    return a + (b - a) * t;
  }
  
  /// 计算曲线的切线向量
  Point2D tangentAt(double t) {
    final oneMinusT = 1 - t;
    final oneMinusT2 = oneMinusT * oneMinusT;
    final t2 = t * t;
    
    return (p1 - p0) * (3 * oneMinusT2) +
           (p2 - p1) * (6 * oneMinusT * t) +
           (p3 - p2) * (3 * t2);
  }
  
  /// 计算曲线的法线向量
  Point2D normalAt(double t) {
    final tangent = tangentAt(t);
    return Point2D(-tangent.y, tangent.x);
  }
  
  /// 生成曲线上的点序列
  List<Point2D> generatePoints(int segmentCount) {
    final points = <Point2D>[];
    for (int i = 0; i <= segmentCount; i++) {
      final t = i / segmentCount;
      points.add(pointAt(t));
    }
    return points;
  }
}

🎚️ 2.3 曲线细分与自适应采样

在实际应用中,我们需要将贝塞尔曲线转换为一系列线段进行绘制。为了保证绘制质量,我们需要根据曲线的曲率自适应地调整采样密度。

dart 复制代码
/// 自适应贝塞尔曲线采样器
class AdaptiveBezierSampler {
  final double tolerance;  // 🎚️ 容差值,控制采样精度
  
  AdaptiveBezierSampler({this.tolerance = 1.0});
  
  /// 自适应采样
  List<Point2D> sample(CubicBezierCurve curve) {
    final points = <Point2D>[];
    _sampleRecursive(curve, 0, 1, points);
    return points;
  }
  
  /// 递归细分采样
  void _sampleRecursive(
    CubicBezierCurve curve,
    double t0,
    double t1,
    List<Point2D> points,
  ) {
    final p0 = curve.pointAt(t0);
    final p1 = curve.pointAt((t0 + t1) / 2);
    final p2 = curve.pointAt(t1);
    
    // 计算中点到弦的距离
    final distance = _pointToLineDistance(p1, p0, p2);
    
    if (distance < tolerance) {
      // ✅ 曲线足够平直,直接添加端点
      if (points.isEmpty || points.last != p0) {
        points.add(p0);
      }
      points.add(p2);
    } else {
      // 🔄 曲线弯曲,继续细分
      final midT = (t0 + t1) / 2;
      _sampleRecursive(curve, t0, midT, points);
      _sampleRecursive(curve, midT, t1, points);
    }
  }
  
  /// 计算点到线段的距离
  double _pointToLineDistance(Point2D point, Point2D lineStart, Point2D lineEnd) {
    final line = lineEnd - lineStart;
    final lineLength = line.distance;
    
    if (lineLength == 0) {
      return (point - lineStart).distance;
    }
    
    final t = max(0, min(1, point.dot(line) / (lineLength * lineLength)));
    final projection = lineStart + line * t;
    
    return (point - projection).distance;
  }
}

/// 点类的扩展方法
extension Point2DExtension on Point2D {
  double dot(Point2D other) => x * other.x + y * other.y;
}

🌬️ 三、流体动画的"呼吸感"设计

💭 3.1 什么是"呼吸感"?

"呼吸感"是动画设计中一个重要的美学概念,指的是动画效果具有类似生命体呼吸的节奏感和韵律感。具有"呼吸感"的动画通常具有以下特征:

特征 说明 示例
🔄 周期性 动画按照一定的周期循环往复,就像呼吸的吸气和呼气 波浪起伏
📈 缓动变化 动画速度不是恒定的,而是有加速和减速的过程 ease-in-out
🌿 有机性 动画轨迹不是机械的直线或圆弧,而是具有自然弯曲的曲线 水流效果
🎵 响应性 动画能够对外部刺激(如音乐节奏)做出反应 音频可视化

🎨 设计哲学:好的"呼吸感"动画应该让用户感受到一种自然的、有生命力的节奏,而不是机械的、冰冷的重复运动。


🎛️ 3.2 音乐驱动的曲线变形

在音乐可视化中,我们可以使用音频数据来驱动贝塞尔曲线的控制点,创造出随音乐律动的流体效果。

dart 复制代码
import 'dart:typed_data';
import 'dart:math';

/// 音频驱动的贝塞尔曲线
class AudioDrivenBezier {
  final CubicBezierCurve baseCurve;  // 📐 基础曲线
  final double amplitude;             // 📊 变形幅度
  final double phase;                 // 🔄 相位偏移
  
  AudioDrivenBezier({
    required this.baseCurve,
    this.amplitude = 50,
    this.phase = 0,
  });
  
  /// 根据音频数据生成变形曲线
  CubicBezierCurve deform(Float32List audioData, double time) {
    // 从音频数据中提取特征
    final energy = _calculateEnergy(audioData);
    final frequency = _estimateFrequency(audioData);
    
    // 计算控制点的偏移
    final offset1 = _calculateOffset(energy, frequency, time, 0);
    final offset2 = _calculateOffset(energy, frequency, time, pi / 2);
    
    // 创建变形后的曲线
    return CubicBezierCurve(
      baseCurve.p0,
      Point2D(baseCurve.p1.x + offset1.x, baseCurve.p1.y + offset1.y),
      Point2D(baseCurve.p2.x + offset2.x, baseCurve.p2.y + offset2.y),
      baseCurve.p3,
    );
  }
  
  /// 计算音频能量 ⚡
  double _calculateEnergy(Float32List audioData) {
    double sum = 0;
    for (int i = 0; i < audioData.length; i++) {
      sum += audioData[i] * audioData[i];
    }
    return sqrt(sum / audioData.length);
  }
  
  /// 估计主频率 📻
  double _estimateFrequency(Float32List audioData) {
    int crossings = 0;
    for (int i = 1; i < audioData.length; i++) {
      if ((audioData[i - 1] >= 0 && audioData[i] < 0) ||
          (audioData[i - 1] < 0 && audioData[i] >= 0)) {
        crossings++;
      }
    }
    return crossings / audioData.length;
  }
  
  /// 计算控制点偏移 📍
  Point2D _calculateOffset(double energy, double frequency, double time, double phaseOffset) {
    final wave = sin(time * frequency * 10 + phase + phaseOffset);
    final offset = energy * amplitude * wave;
    
    return Point2D(offset * cos(phase + phaseOffset), offset * sin(phase + phaseOffset));
  }
}

🌊 3.3 多曲线组合的流体效果

单条贝塞尔曲线的效果有限,我们可以组合多条曲线,创造出更加丰富的流体效果。

dart 复制代码
/// 流体曲线系统
class FluidCurveSystem {
  final List<CubicBezierCurve> curves;
  final List<double> phases;
  final double spread;  // 📐 曲线间的扩散角度
  
  FluidCurveSystem({
    required this.curves,
    required this.phases,
    this.spread = 0.1,
  });
  
  /// 更新曲线状态
  List<CubicBezierCurve> update(Float32List audioData, double time) {
    final updatedCurves = <CubicBezierCurve>[];
    
    for (int i = 0; i < curves.length; i++) {
      final curve = curves[i];
      final phase = phases[i];
      
      // 计算该曲线的音频响应
      final response = _calculateResponse(audioData, i, curves.length);
      
      // 创建变形曲线
      final deformed = _deformCurve(curve, response, time, phase);
      updatedCurves.add(deformed);
    }
    
    return updatedCurves;
  }
  
  /// 计算曲线的音频响应 🎵
  double _calculateResponse(Float32List audioData, int index, int total) {
    final segmentSize = audioData.length ~/ total;
    final start = index * segmentSize;
    final end = min(start + segmentSize, audioData.length);
    
    double sum = 0;
    for (int i = start; i < end; i++) {
      sum += audioData[i].abs();
    }
    
    return sum / (end - start);
  }
  
  /// 变形曲线 🌊
  CubicBezierCurve _deformCurve(CubicBezierCurve curve, double response, double time, double phase) {
    final wave = sin(time * 2 + phase) * response * 30;
    
    return CubicBezierCurve(
      curve.p0,
      Point2D(curve.p1.x + wave * cos(phase), curve.p1.y + wave * sin(phase)),
      Point2D(curve.p2.x - wave * cos(phase), curve.p2.y - wave * sin(phase)),
      curve.p3,
    );
  }
}

🎵 3.4 网络音乐播放与实时音频分析

在实际的音乐可视化应用中,我们需要播放网络 MP3 并实时提取音频数据来驱动贝塞尔曲线动画。下面介绍如何使用 just_audio_ohos 实现 OpenHarmony 平台的网络音乐播放。

📦 3.4.1 添加依赖

pubspec.yaml 中添加 OpenHarmony 适配的音频播放库:

yaml 复制代码
dependencies:
  # 音频播放(OpenHarmony 适配版本)
  just_audio_ohos:
    git:
      url: https://atomgit.com/openharmony-sig/fluttertpc_just_audio
      path: just_audio/ohos
  audio_session:
    git:
      url: https://atomgit.com/openharmony-sig/flutter_audio_session.git
🎧 3.4.2 音乐播放控制器
dart 复制代码
import 'package:flutter/material.dart';
import 'package:just_audio_ohos/just_audio_ohos.dart';
import 'package:audio_session/audio_session.dart';
import 'dart:typed_data';
import 'dart:math';

/// 音乐播放控制器
class MusicPlayerController extends ChangeNotifier {
  final AudioPlayer _player = AudioPlayer();
  bool _isPlaying = false;
  bool _isInitialized = false;
  Duration _position = Duration.zero;
  Duration _duration = Duration.zero;
  Float32List? _audioData;
  
  bool get isPlaying => _isPlaying;
  bool get isInitialized => _isInitialized;
  Duration get position => _position;
  Duration get duration => _duration;
  Float32List? get audioData => _audioData;
  AudioPlayer get player => _player;
  
  /// 初始化播放器
  Future<void> initialize() async {
    // 配置音频会话
    final session = await AudioSession.instance;
    await session.configure(const AudioSessionConfiguration.music());
    
    // 监听播放状态
    _player.playerStateStream.listen((state) {
      _isPlaying = state.playing;
      notifyListeners();
    });
    
    // 监听播放进度
    _player.positionStream.listen((position) {
      _position = position;
      notifyListeners();
    });
    
    // 监听时长
    _player.durationStream.listen((duration) {
      _duration = duration ?? Duration.zero;
      notifyListeners();
    });
    
    _isInitialized = true;
    notifyListeners();
  }
  
  /// 加载网络 MP3
  Future<void> loadNetworkAudio(String url) async {
    try {
      await _player.setUrl(url);
      _isInitialized = true;
      notifyListeners();
    } catch (e) {
      debugPrint('加载音频失败: $e');
    }
  }
  
  /// 播放/暂停切换
  Future<void> togglePlay() async {
    if (_isPlaying) {
      await _player.pause();
    } else {
      await _player.play();
    }
  }
  
  /// 跳转到指定位置
  Future<void> seek(Duration position) async {
    await _player.seek(position);
  }
  
  /// 模拟音频数据(用于可视化)
  /// 注:just_audio 不直接提供音频波形数据,这里使用模拟数据
  Float32List generateSimulatedAudioData() {
    final data = Float32List(128);
    final random = Random();
    final time = DateTime.now().millisecondsSinceEpoch / 1000.0;
    
    for (int i = 0; i < 128; i++) {
      // 基于时间和频率生成模拟音频数据
      final freq = (i / 128) * 10;
      final wave1 = sin(time * freq) * 0.5;
      final wave2 = sin(time * freq * 2 + pi / 4) * 0.3;
      final noise = (random.nextDouble() - 0.5) * 0.2;
      
      // 如果正在播放,数据更活跃
      if (_isPlaying) {
        data[i] = (wave1 + wave2 + noise) * (0.5 + random.nextDouble() * 0.5);
      } else {
        data[i] = wave1 * 0.1;
      }
    }
    
    _audioData = data;
    return data;
  }
  
  @override
  void dispose() {
    _player.dispose();
    super.dispose();
  }
}
🎨 3.4.3 音乐播放器 UI 组件
dart 复制代码
/// 音乐播放器 Widget
class MusicPlayerWidget extends StatefulWidget {
  final String audioUrl;
  final String title;
  final String artist;
  
  const MusicPlayerWidget({
    super.key,
    required this.audioUrl,
    this.title = '未知曲目',
    this.artist = '未知艺术家',
  });

  @override
  State<MusicPlayerWidget> createState() => _MusicPlayerWidgetState();
}

class _MusicPlayerWidgetState extends State<MusicPlayerWidget> {
  late MusicPlayerController _controller;
  
  @override
  void initState() {
    super.initState();
    _controller = MusicPlayerController();
    _initPlayer();
  }
  
  Future<void> _initPlayer() async {
    await _controller.initialize();
    await _controller.loadNetworkAudio(widget.audioUrl);
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.black.withOpacity(0.6),
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          // 曲目信息
          Text(
            widget.title,
            style: const TextStyle(
              color: Colors.white,
              fontSize: 18,
              fontWeight: FontWeight.bold,
            ),
          ),
          const SizedBox(height: 4),
          Text(
            widget.artist,
            style: TextStyle(
              color: Colors.white.withOpacity(0.7),
              fontSize: 14,
            ),
          ),
          const SizedBox(height: 16),
          
          // 进度条
          ListenableBuilder(
            listenable: _controller,
            builder: (context, child) {
              final duration = _controller.duration.inMilliseconds.toDouble();
              final position = _controller.position.inMilliseconds.toDouble();
              
              return Column(
                children: [
                  SliderTheme(
                    data: SliderTheme.of(context).copyWith(
                      activeTrackColor: Colors.cyan,
                      inactiveTrackColor: Colors.grey,
                      thumbColor: Colors.cyan,
                    ),
                    child: Slider(
                      value: duration > 0 ? position.clamp(0, duration) : 0,
                      max: duration > 0 ? duration : 1,
                      onChanged: (value) {
                        _controller.seek(Duration(milliseconds: value.toInt()));
                      },
                    ),
                  ),
                  Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 16),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        Text(
                          _formatDuration(_controller.position),
                          style: const TextStyle(color: Colors.white70, fontSize: 12),
                        ),
                        Text(
                          _formatDuration(_controller.duration),
                          style: const TextStyle(color: Colors.white70, fontSize: 12),
                        ),
                      ],
                    ),
                  ),
                ],
              );
            },
          ),
          const SizedBox(height: 16),
          
          // 控制按钮
          ListenableBuilder(
            listenable: _controller,
            builder: (context, child) {
              return IconButton(
                icon: Icon(
                  _controller.isPlaying ? Icons.pause : Icons.play_arrow,
                  color: Colors.white,
                  size: 48,
                ),
                onPressed: _controller.togglePlay,
              );
            },
          ),
        ],
      ),
    );
  }
  
  String _formatDuration(Duration d) {
    final minutes = d.inMinutes;
    final seconds = d.inSeconds.remainder(60);
    return '$minutes:${seconds.toString().padLeft(2, '0')}';
  }
}
🌊 3.4.4 音乐驱动的贝塞尔流体动画完整示例
dart 复制代码
import 'package:flutter/material.dart';
import 'package:just_audio_ohos/just_audio_ohos.dart';
import 'package:audio_session/audio_session.dart';
import 'dart:typed_data';
import 'dart:math';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '音乐驱动贝塞尔流体',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.cyan,
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
      ),
      home: const MusicBezierPage(),
      debugShowCheckedModeBanner: false,
    );
  }
}

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

  @override
  State<MusicBezierPage> createState() => _MusicBezierPageState();
}

class _MusicBezierPageState extends State<MusicBezierPage> with TickerProviderStateMixin {
  late AnimationController _animController;
  late AudioPlayer _audioPlayer;
  final Random _random = Random();
  Float32List _audioData = Float32List(128);
  bool _isPlaying = false;
  Duration _position = Duration.zero;
  Duration _duration = Duration.zero;
  
  // 示例网络 MP3 URL(可替换为实际的音乐链接)
  static const String _sampleAudioUrl = 
      'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3';

  @override
  void initState() {
    super.initState();
    _initAudio();
    _animController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 16),
    )..repeat();
    _animController.addListener(_updateAudioData);
  }
  
  Future<void> _initAudio() async {
    _audioPlayer = AudioPlayer();
    
    // 配置音频会话
    final session = await AudioSession.instance;
    await session.configure(const AudioSessionConfiguration.music());
    
    // 监听播放状态
    _audioPlayer.playerStateStream.listen((state) {
      setState(() {
        _isPlaying = state.playing;
      });
    });
    
    // 监听进度
    _audioPlayer.positionStream.listen((position) {
      setState(() {
        _position = position;
      });
    });
    
    // 监听时长
    _audioPlayer.durationStream.listen((duration) {
      setState(() {
        _duration = duration ?? Duration.zero;
      });
    });
    
    // 加载网络音频
    try {
      await _audioPlayer.setUrl(_sampleAudioUrl);
    } catch (e) {
      debugPrint('加载音频失败: $e');
    }
  }

  void _updateAudioData() {
    final time = DateTime.now().millisecondsSinceEpoch / 1000.0;
    
    for (int i = 0; i < 128; i++) {
      if (_isPlaying) {
        // 播放时生成活跃的音频数据
        final freq = (i / 128) * 8 + 1;
        final wave1 = sin(time * freq) * 0.4;
        final wave2 = sin(time * freq * 1.5 + pi / 3) * 0.3;
        final wave3 = sin(time * freq * 0.5 + pi / 6) * 0.2;
        final noise = (_random.nextDouble() - 0.5) * 0.15;
        
        // 低频部分更活跃(模拟低音效果)
        final bassBoost = i < 32 ? 0.3 : 0;
        
        _audioData[i] = _audioData[i] * 0.7 + 
            (wave1 + wave2 + wave3 + noise + bassBoost) * 0.3;
      } else {
        // 暂停时数据衰减
        _audioData[i] = _audioData[i] * 0.95;
      }
    }
    
    setState(() {});
  }

  @override
  void dispose() {
    _animController.dispose();
    _audioPlayer.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          // 贝塞尔流体动画背景
          Container(
            decoration: const BoxDecoration(
              gradient: LinearGradient(
                begin: Alignment.topCenter,
                end: Alignment.bottomCenter,
                colors: [Colors.black, Color(0xFF0A1628), Colors.black],
              ),
            ),
            child: CustomPaint(
              painter: MusicDrivenBezierPainter(
                audioData: _audioData,
                time: DateTime.now().millisecondsSinceEpoch / 1000.0,
                isPlaying: _isPlaying,
              ),
              size: Size.infinite,
            ),
          ),
          
          // 音乐播放器控制面板
          Positioned(
            bottom: 40,
            left: 20,
            right: 20,
            child: _buildPlayerControls(),
          ),
        ],
      ),
    );
  }
  
  Widget _buildPlayerControls() {
    return Container(
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: Colors.black.withOpacity(0.7),
        borderRadius: BorderRadius.circular(20),
        border: Border.all(
          color: Colors.cyan.withOpacity(0.3),
          width: 1,
        ),
        boxShadow: [
          BoxShadow(
            color: Colors.cyan.withOpacity(0.1),
            blurRadius: 20,
            spreadRadius: 5,
          ),
        ],
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          // 标题
          const Text(
            '🎵 贝塞尔流体律动',
            style: TextStyle(
              color: Colors.white,
              fontSize: 18,
              fontWeight: FontWeight.bold,
            ),
          ),
          const SizedBox(height: 4),
          Text(
            'SoundHelix - Song 1',
            style: TextStyle(
              color: Colors.white.withOpacity(0.7),
              fontSize: 14,
            ),
          ),
          const SizedBox(height: 16),
          
          // 进度条
          SliderTheme(
            data: SliderTheme.of(context).copyWith(
              activeTrackColor: Colors.cyan,
              inactiveTrackColor: Colors.grey.shade800,
              thumbColor: Colors.cyan,
              overlayColor: Colors.cyan.withOpacity(0.2),
            ),
            child: Slider(
              value: _duration.inMilliseconds > 0 
                  ? _position.inMilliseconds.toDouble().clamp(0, _duration.inMilliseconds.toDouble())
                  : 0,
              max: _duration.inMilliseconds > 0 
                  ? _duration.inMilliseconds.toDouble() 
                  : 1,
              onChanged: (value) {
                _audioPlayer.seek(Duration(milliseconds: value.toInt()));
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 8),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  _formatDuration(_position),
                  style: const TextStyle(color: Colors.white70, fontSize: 12),
                ),
                Text(
                  _formatDuration(_duration),
                  style: const TextStyle(color: Colors.white70, fontSize: 12),
                ),
              ],
            ),
          ),
          const SizedBox(height: 12),
          
          // 控制按钮
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              // 后退按钮
              IconButton(
                icon: const Icon(Icons.replay_10, color: Colors.white70, size: 28),
                onPressed: () {
                  final newPos = _position - const Duration(seconds: 10);
                  _audioPlayer.seek(newPos.isNegative ? Duration.zero : newPos);
                },
              ),
              const SizedBox(width: 20),
              
              // 播放/暂停按钮
              Container(
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  gradient: LinearGradient(
                    begin: Alignment.topLeft,
                    end: Alignment.bottomRight,
                    colors: [Colors.cyan.shade400, Colors.cyan.shade700],
                  ),
                  boxShadow: [
                    BoxShadow(
                      color: Colors.cyan.withOpacity(0.4),
                      blurRadius: 15,
                      spreadRadius: 2,
                    ),
                  ],
                ),
                child: IconButton(
                  icon: Icon(
                    _isPlaying ? Icons.pause : Icons.play_arrow,
                    color: Colors.white,
                    size: 36,
                  ),
                  onPressed: () {
                    if (_isPlaying) {
                      _audioPlayer.pause();
                    } else {
                      _audioPlayer.play();
                    }
                  },
                ),
              ),
              const SizedBox(width: 20),
              
              // 前进按钮
              IconButton(
                icon: const Icon(Icons.forward_10, color: Colors.white70, size: 28),
                onPressed: () {
                  final newPos = _position + const Duration(seconds: 10);
                  _audioPlayer.seek(newPos > _duration ? _duration : newPos);
                },
              ),
            ],
          ),
        ],
      ),
    );
  }
  
  String _formatDuration(Duration d) {
    final minutes = d.inMinutes;
    final seconds = d.inSeconds.remainder(60);
    return '$minutes:${seconds.toString().padLeft(2, '0')}';
  }
}

/// 音乐驱动的贝塞尔曲线绘制器
class MusicDrivenBezierPainter extends CustomPainter {
  final Float32List audioData;
  final double time;
  final bool isPlaying;
  
  MusicDrivenBezierPainter({
    required this.audioData,
    required this.time,
    required this.isPlaying,
  });
  
  @override
  void paint(Canvas canvas, Size size) {
    final curveCount = 15;
    final segmentSize = audioData.length ~/ curveCount;
    
    // 绘制多层贝塞尔曲线
    for (int i = 0; i < curveCount; i++) {
      // 计算该段的音频能量
      double energy = 0;
      for (int j = 0; j < segmentSize; j++) {
        energy += audioData[i * segmentSize + j].abs();
      }
      energy /= segmentSize;
      
      // 基础 Y 位置
      final baseY = size.height * (0.1 + i * 0.055);
      
      // 波形参数
      final wave1 = sin(time * 2 + i * 0.4) * 40 * energy;
      final wave2 = sin(time * 3 + i * 0.3) * 30 * energy;
      final wave3 = cos(time * 1.5 + i * 0.5) * 20 * energy;
      
      // 颜色 - 基于位置和能量
      final hue = (i / curveCount * 180 + time * 15) % 360;
      final saturation = 0.7 + energy * 0.3;
      final color = HSVColor.fromAHSV(
        0.5 + energy * 0.3,
        hue,
        saturation,
        0.9 + energy * 0.1,
      ).toColor();
      
      // 创建贝塞尔曲线路径
      final path = Path();
      path.moveTo(-10, baseY + wave1);
      
      // 三阶贝塞尔曲线
      path.cubicTo(
        size.width * 0.25, baseY - 50 * energy + wave2,
        size.width * 0.75, baseY + 50 * energy + wave3,
        size.width + 10, baseY + wave1,
      );
      
      // 闭合路径形成填充区域
      path.lineTo(size.width + 10, size.height + 10);
      path.lineTo(-10, size.height + 10);
      path.close();
      
      // 渐变填充
      final gradient = LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter,
        colors: [
          color,
          color.withOpacity(0.2),
          color.withOpacity(0.05),
        ],
        stops: const [0.0, 0.5, 1.0],
      );
      
      final paint = Paint()
        ..shader = gradient.createShader(Rect.fromLTWH(0, 0, size.width, size.height))
        ..style = PaintingStyle.fill;
      
      canvas.drawPath(path, paint);
      
      // 绘制曲线描边
      final strokePaint = Paint()
        ..color = color.withOpacity(0.8)
        ..strokeWidth = 1.5 + energy
        ..style = PaintingStyle.stroke
        ..strokeCap = StrokeCap.round;
      
      final strokePath = Path();
      strokePath.moveTo(-10, baseY + wave1);
      strokePath.cubicTo(
        size.width * 0.25, baseY - 50 * energy + wave2,
        size.width * 0.75, baseY + 50 * energy + wave3,
        size.width + 10, baseY + wave1,
      );
      
      canvas.drawPath(strokePath, strokePaint);
    }
    
    // 绘制发光粒子效果
    if (isPlaying) {
      _drawParticles(canvas, size);
    }
  }
  
  void _drawParticles(Canvas canvas, Size size) {
    final random = Random(time.toInt());
    final particleCount = 30;
    
    for (int i = 0; i < particleCount; i++) {
      final x = random.nextDouble() * size.width;
      final y = random.nextDouble() * size.height;
      final radius = random.nextDouble() * 3 + 1;
      
      final hue = (i / particleCount * 360 + time * 30) % 360;
      final color = HSVColor.fromAHSV(0.6, hue, 1, 1).toColor();
      
      final paint = Paint()
        ..color = color
        ..style = PaintingStyle.fill
        ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3);
      
      canvas.drawCircle(Offset(x, y), radius, paint);
    }
  }
  
  @override
  bool shouldRepaint(covariant MusicDrivenBezierPainter oldDelegate) => true;
}
📝 3.4.5 关键实现说明
要点 说明
🎧 音频会话配置 使用 AudioSession 配置音频焦点,避免与其他应用冲突
🌐 网络音频加载 使用 setUrl() 方法加载网络 MP3 文件
📊 模拟音频数据 由于 just_audio 不直接提供波形数据,使用数学函数模拟
🔄 实时更新 通过 AnimationController 高频更新音频数据和动画
🎨 能量映射 将音频能量映射到曲线的振幅和颜色

💡 提示 :如需获取真实音频波形数据,可以考虑使用 flutter_soundfftea 等库进行 FFT 分析。


🎨 四、Flutter 实现贝塞尔曲线绘制

🖌️ 4.1 CustomPainter 绘制基础

Flutter 的 CustomPainter 类提供了强大的自定义绘制能力。我们可以使用 CanvasdrawPath 方法来绘制贝塞尔曲线。

dart 复制代码
import 'package:flutter/material.dart';

/// 贝塞尔曲线绘制器
class BezierCurvePainter extends CustomPainter {
  final CubicBezierCurve curve;
  final Color color;
  final double strokeWidth;
  final bool showControlPoints;
  
  BezierCurvePainter({
    required this.curve,
    required this.color,
    this.strokeWidth = 2,
    this.showControlPoints = false,
  });
  
  @override
  void paint(Canvas canvas, Size size) {
    // 创建路径
    final path = Path();
    path.moveTo(curve.p0.x, curve.p0.y);
    
    // 使用 cubicTo 绘制三阶贝塞尔曲线
    path.cubicTo(
      curve.p1.x, curve.p1.y,  // 🔵 控制点1
      curve.p2.x, curve.p2.y,  // 🔵 控制点2
      curve.p3.x, curve.p3.y,  // 🔴 终点
    );
    
    // 绘制曲线
    final paint = Paint()
      ..color = color
      ..strokeWidth = strokeWidth
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;
    
    canvas.drawPath(path, paint);
    
    // 绘制控制点(可选)
    if (showControlPoints) {
      _drawControlPoints(canvas);
    }
  }
  
  void _drawControlPoints(Canvas canvas) {
    final pointPaint = Paint()
      ..color = Colors.red
      ..style = PaintingStyle.fill;
    
    final linePaint = Paint()
      ..color = Colors.red.withOpacity(0.5)
      ..strokeWidth = 1
      ..style = PaintingStyle.stroke;
    
    // 绘制控制点
    canvas.drawCircle(Offset(curve.p0.x, curve.p0.y), 5, pointPaint..color = Colors.green);
    canvas.drawCircle(Offset(curve.p1.x, curve.p1.y), 5, pointPaint..color = Colors.blue);
    canvas.drawCircle(Offset(curve.p2.x, curve.p2.y), 5, pointPaint..color = Colors.blue);
    canvas.drawCircle(Offset(curve.p3.x, curve.p3.y), 5, pointPaint..color = Colors.green);
    
    // 绘制控制线
    canvas.drawLine(
      Offset(curve.p0.x, curve.p0.y),
      Offset(curve.p1.x, curve.p1.y),
      linePaint,
    );
    canvas.drawLine(
      Offset(curve.p2.x, curve.p2.y),
      Offset(curve.p3.x, curve.p3.y),
      linePaint,
    );
  }
  
  @override
  bool shouldRepaint(covariant BezierCurvePainter oldDelegate) {
    return curve != oldDelegate.curve || color != oldDelegate.color;
  }
}

🌈 4.2 渐变填充的流体曲线

为了增强视觉效果,我们可以为流体曲线添加渐变填充效果。

dart 复制代码
/// 渐变流体曲线绘制器
class GradientFluidPainter extends CustomPainter {
  final List<CubicBezierCurve> curves;
  final List<Color> colors;
  final double animationValue;
  
  GradientFluidPainter({
    required this.curves,
    required this.colors,
    required this.animationValue,
  });
  
  @override
  void paint(Canvas canvas, Size size) {
    for (int i = 0; i < curves.length; i++) {
      final curve = curves[i];
      final color = colors[i % colors.length];
      
      // 创建填充路径
      final path = Path();
      path.moveTo(curve.p0.x, curve.p0.y);
      path.cubicTo(
        curve.p1.x, curve.p1.y,
        curve.p2.x, curve.p2.y,
        curve.p3.x, curve.p3.y,
      );
      
      // 闭合路径(连接到底部)
      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
      path.close();
      
      // 创建渐变 🌈
      final gradient = LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter,
        colors: [
          color.withOpacity(0.8),
          color.withOpacity(0.3),
          color.withOpacity(0.1),
        ],
        stops: const [0.0, 0.5, 1.0],
      );
      
      final paint = Paint()
        ..shader = gradient.createShader(Rect.fromLTWH(0, 0, size.width, size.height))
        ..style = PaintingStyle.fill;
      
      canvas.drawPath(path, paint);
      
      // 绘制曲线描边
      final strokePaint = Paint()
        ..color = color
        ..strokeWidth = 2
        ..style = PaintingStyle.stroke;
      
      final strokePath = Path();
      strokePath.moveTo(curve.p0.x, curve.p0.y);
      strokePath.cubicTo(
        curve.p1.x, curve.p1.y,
        curve.p2.x, curve.p2.y,
        curve.p3.x, curve.p3.y,
      );
      
      canvas.drawPath(strokePath, strokePaint);
    }
  }
  
  @override
  bool shouldRepaint(covariant GradientFluidPainter oldDelegate) {
    return curves != oldDelegate.curves || animationValue != oldDelegate.animationValue;
  }
}

📦 五、完整示例代码

以下是完整的贝塞尔流体律动示例代码,包含网络音乐播放功能:

dart 复制代码
import 'package:flutter/material.dart';
import 'package:just_audio_ohos/just_audio_ohos.dart';
import 'package:audio_session/audio_session.dart';
import 'dart:math';
import 'dart:typed_data';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '贝塞尔流体律动',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.cyan,
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
      ),
      home: const BezierHomePage(),
      debugShowCheckedModeBanner: false,
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('🌊 贝塞尔流体律动'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          _buildSectionCard(
            context,
            title: '基础曲线',
            description: '三阶贝塞尔曲线演示',
            icon: Icons.show_chart,
            color: Colors.blue,
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const BasicBezierDemo()),
            ),
          ),
          _buildSectionCard(
            context,
            title: '控制点编辑',
            description: '交互式曲线编辑',
            icon: Icons.control_point,
            color: Colors.green,
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const ControlPointDemo()),
            ),
          ),
          _buildSectionCard(
            context,
            title: '流体动画',
            description: '呼吸感动画效果',
            icon: Icons.waves,
            color: Colors.purple,
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const FluidAnimationDemo()),
            ),
          ),
          _buildSectionCard(
            context,
            title: '音频响应',
            description: '音乐驱动曲线变形',
            icon: Icons.music_note,
            color: Colors.orange,
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const AudioResponsiveDemo()),
            ),
          ),
          _buildSectionCard(
            context,
            title: '综合演示',
            description: '完整流体律动效果',
            icon: Icons.water_drop,
            color: Colors.cyan,
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const FullFluidDemo()),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildSectionCard(
    BuildContext context, {
    required String title,
    required String description,
    required IconData icon,
    required Color color,
    required VoidCallback onTap,
  }) {
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
      child: InkWell(
        onTap: onTap,
        borderRadius: BorderRadius.circular(16),
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            children: [
              Container(
                width: 56,
                height: 56,
                decoration: BoxDecoration(
                  color: color.withOpacity(0.1),
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Icon(icon, color: color, size: 28),
              ),
              const SizedBox(width: 16),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      title,
                      style: const TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    const SizedBox(height: 4),
                    Text(
                      description,
                      style: TextStyle(color: Colors.grey[600], fontSize: 14),
                    ),
                  ],
                ),
              ),
              Icon(Icons.chevron_right, color: Colors.grey[400]),
            ],
          ),
        ),
      ),
    );
  }
}

class Point2D {
  double x;
  double y;
  
  Point2D(this.x, this.y);
  
  Point2D operator +(Point2D other) => Point2D(x + other.x, y + other.y);
  Point2D operator -(Point2D other) => Point2D(x - other.x, y - other.y);
  Point2D operator *(double scalar) => Point2D(x * scalar, y * scalar);
  
  double dot(Point2D other) => x * other.x + y * other.y;
  double get distance => sqrt(x * x + y * y);
}

class CubicBezierCurve {
  final Point2D p0;
  final Point2D p1;
  final Point2D p2;
  final Point2D p3;
  
  CubicBezierCurve(this.p0, this.p1, this.p2, this.p3);
  
  Point2D pointAt(double t) {
    final oneMinusT = 1 - t;
    final oneMinusT2 = oneMinusT * oneMinusT;
    final oneMinusT3 = oneMinusT2 * oneMinusT;
    final t2 = t * t;
    final t3 = t2 * t;
    
    return Point2D(
      oneMinusT3 * p0.x + 3 * oneMinusT2 * t * p1.x + 3 * oneMinusT * t2 * p2.x + t3 * p3.x,
      oneMinusT3 * p0.y + 3 * oneMinusT2 * t * p1.y + 3 * oneMinusT * t2 * p2.y + t3 * p3.y,
    );
  }
  
  Point2D tangentAt(double t) {
    final oneMinusT = 1 - t;
    final oneMinusT2 = oneMinusT * oneMinusT;
    final t2 = t * t;
    
    return Point2D(
      3 * oneMinusT2 * (p1.x - p0.x) + 6 * oneMinusT * t * (p2.x - p1.x) + 3 * t2 * (p3.x - p2.x),
      3 * oneMinusT2 * (p1.y - p0.y) + 6 * oneMinusT * t * (p2.y - p1.y) + 3 * t2 * (p3.y - p2.y),
    );
  }
  
  List<Point2D> generatePoints(int count) {
    return List.generate(count + 1, (i) => pointAt(i / count));
  }
}

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

  @override
  Widget build(BuildContext context) {
    final curve = CubicBezierCurve(
      Point2D(50, 300),
      Point2D(150, 100),
      Point2D(250, 500),
      Point2D(350, 300),
    );
    
    return Scaffold(
      appBar: AppBar(title: const Text('基础曲线')),
      body: Container(
        color: Colors.black,
        child: CustomPaint(
          painter: BasicBezierPainter(curve: curve),
          size: Size.infinite,
        ),
      ),
    );
  }
}

class BasicBezierPainter extends CustomPainter {
  final CubicBezierCurve curve;
  
  BasicBezierPainter({required this.curve});
  
  @override
  void paint(Canvas canvas, Size size) {
    final path = Path();
    path.moveTo(curve.p0.x, curve.p0.y);
    path.cubicTo(curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y);
    
    final curvePaint = Paint()
      ..color = Colors.cyan
      ..strokeWidth = 3
      ..style = PaintingStyle.stroke;
    
    canvas.drawPath(path, curvePaint);
    
    final controlPaint = Paint()
      ..color = Colors.red
      ..style = PaintingStyle.fill;
    
    canvas.drawCircle(Offset(curve.p0.x, curve.p0.y), 6, controlPaint);
    canvas.drawCircle(Offset(curve.p3.x, curve.p3.y), 6, controlPaint);
    
    controlPaint.color = Colors.green;
    canvas.drawCircle(Offset(curve.p1.x, curve.p1.y), 4, controlPaint);
    canvas.drawCircle(Offset(curve.p2.x, curve.p2.y), 4, controlPaint);
    
    final linePaint = Paint()
      ..color = Colors.green.withOpacity(0.5)
      ..strokeWidth = 1
      ..style = PaintingStyle.stroke;
    
    canvas.drawLine(Offset(curve.p0.x, curve.p0.y), Offset(curve.p1.x, curve.p1.y), linePaint);
    canvas.drawLine(Offset(curve.p2.x, curve.p2.y), Offset(curve.p3.x, curve.p3.y), linePaint);
  }
  
  @override
  bool shouldRepaint(covariant BasicBezierPainter oldDelegate) => curve != oldDelegate.curve;
}

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

  @override
  State<ControlPointDemo> createState() => _ControlPointDemoState();
}

class _ControlPointDemoState extends State<ControlPointDemo> {
  Point2D p0 = Point2D(50, 300);
  Point2D p1 = Point2D(150, 100);
  Point2D p2 = Point2D(250, 500);
  Point2D p3 = Point2D(350, 300);
  
  int? selectedPoint;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('控制点编辑')),
      body: GestureDetector(
        onPanStart: (details) {
          final dx = details.localPosition.dx;
          final dy = details.localPosition.dy;
          
          if ((dx - p1.x).abs() < 20 && (dy - p1.y).abs() < 20) {
            selectedPoint = 1;
          } else if ((dx - p2.x).abs() < 20 && (dy - p2.y).abs() < 20) {
            selectedPoint = 2;
          }
        },
        onPanUpdate: (details) {
          if (selectedPoint == 1) {
            setState(() {
              p1 = Point2D(details.localPosition.dx, details.localPosition.dy);
            });
          } else if (selectedPoint == 2) {
            setState(() {
              p2 = Point2D(details.localPosition.dx, details.localPosition.dy);
            });
          }
        },
        onPanEnd: (_) {
          selectedPoint = null;
        },
        child: Container(
          color: Colors.black,
          child: CustomPaint(
            painter: ControlPointPainter(
              curve: CubicBezierCurve(p0, p1, p2, p3),
              selectedPoint: selectedPoint,
            ),
            size: Size.infinite,
          ),
        ),
      ),
    );
  }
}

class ControlPointPainter extends CustomPainter {
  final CubicBezierCurve curve;
  final int? selectedPoint;
  
  ControlPointPainter({required this.curve, this.selectedPoint});
  
  @override
  void paint(Canvas canvas, Size size) {
    final path = Path();
    path.moveTo(curve.p0.x, curve.p0.y);
    path.cubicTo(curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y);
    
    final curvePaint = Paint()
      ..color = Colors.cyan
      ..strokeWidth = 3
      ..style = PaintingStyle.stroke;
    
    canvas.drawPath(path, curvePaint);
    
    final linePaint = Paint()
      ..color = Colors.green.withOpacity(0.5)
      ..strokeWidth = 1
      ..style = PaintingStyle.stroke;
    
    canvas.drawLine(Offset(curve.p0.x, curve.p0.y), Offset(curve.p1.x, curve.p1.y), linePaint);
    canvas.drawLine(Offset(curve.p2.x, curve.p2.y), Offset(curve.p3.x, curve.p3.y), linePaint);
    
    final endpointPaint = Paint()
      ..color = Colors.red
      ..style = PaintingStyle.fill;
    
    canvas.drawCircle(Offset(curve.p0.x, curve.p0.y), 8, endpointPaint);
    canvas.drawCircle(Offset(curve.p3.x, curve.p3.y), 8, endpointPaint);
    
    final controlPaint = Paint()
      ..color = selectedPoint == 1 || selectedPoint == 2 ? Colors.yellow : Colors.green
      ..style = PaintingStyle.fill;
    
    canvas.drawCircle(Offset(curve.p1.x, curve.p1.y), selectedPoint == 1 ? 12 : 8, controlPaint);
    canvas.drawCircle(Offset(curve.p2.x, curve.p2.y), selectedPoint == 2 ? 12 : 8, controlPaint);
  }
  
  @override
  bool shouldRepaint(covariant ControlPointPainter oldDelegate) => true;
}

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

  @override
  State<FluidAnimationDemo> createState() => _FluidAnimationDemoState();
}

class _FluidAnimationDemoState extends State<FluidAnimationDemo> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 3),
    )..repeat();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('流体动画')),
      body: AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Container(
            decoration: const BoxDecoration(
              gradient: LinearGradient(
                begin: Alignment.topCenter,
                end: Alignment.bottomCenter,
                colors: [Colors.black, Colors.deepPurple],
              ),
            ),
            child: CustomPaint(
              painter: FluidAnimationPainter(_controller.value),
              size: Size.infinite,
            ),
          );
        },
      ),
    );
  }
}

class FluidAnimationPainter extends CustomPainter {
  final double animationValue;
  
  FluidAnimationPainter(this.animationValue);
  
  @override
  void paint(Canvas canvas, Size size) {
    final curves = <CubicBezierCurve>[];
    final colors = <Color>[
      Colors.cyan,
      Colors.blue,
      Colors.purple,
      Colors.pink,
    ];
    
    for (int i = 0; i < 4; i++) {
      final phase = i * pi / 2;
      final wave1 = sin(animationValue * 2 * pi + phase) * 50;
      final wave2 = sin(animationValue * 2 * pi + phase + pi / 4) * 50;
      
      final y = size.height * (0.3 + i * 0.15);
      
      curves.add(CubicBezierCurve(
        Point2D(0, y + wave1 * 0.5),
        Point2D(size.width * 0.33, y - 50 + wave1),
        Point2D(size.width * 0.66, y + 50 + wave2),
        Point2D(size.width, y + wave2 * 0.5),
      ));
    }
    
    for (int i = curves.length - 1; i >= 0; i--) {
      final curve = curves[i];
      final color = colors[i];
      
      final path = Path();
      path.moveTo(curve.p0.x, curve.p0.y);
      path.cubicTo(curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y);
      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
      path.close();
      
      final gradient = LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter,
        colors: [
          color.withOpacity(0.6),
          color.withOpacity(0.2),
        ],
      );
      
      final paint = Paint()
        ..shader = gradient.createShader(Rect.fromLTWH(0, 0, size.width, size.height))
        ..style = PaintingStyle.fill;
      
      canvas.drawPath(path, paint);
    }
  }
  
  @override
  bool shouldRepaint(covariant FluidAnimationPainter oldDelegate) => true;
}

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

  @override
  State<AudioResponsiveDemo> createState() => _AudioResponsiveDemoState();
}

class _AudioResponsiveDemoState extends State<AudioResponsiveDemo> with TickerProviderStateMixin {
  late AnimationController _animController;
  late AudioPlayer _audioPlayer;
  final Random _random = Random();
  Float32List _audioData = Float32List(64);
  bool _isPlaying = false;
  Duration _position = Duration.zero;
  Duration _duration = Duration.zero;
  
  static const String _audioUrl = 
      'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3';

  @override
  void initState() {
    super.initState();
    _initAudio();
    _animController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 16),
    )..repeat();
    _animController.addListener(_updateAudioData);
  }
  
  Future<void> _initAudio() async {
    _audioPlayer = AudioPlayer();
    
    final session = await AudioSession.instance;
    await session.configure(const AudioSessionConfiguration.music());
    
    _audioPlayer.playerStateStream.listen((state) {
      setState(() {
        _isPlaying = state.playing;
      });
    });
    
    _audioPlayer.positionStream.listen((position) {
      setState(() {
        _position = position;
      });
    });
    
    _audioPlayer.durationStream.listen((duration) {
      setState(() {
        _duration = duration ?? Duration.zero;
      });
    });
    
    try {
      await _audioPlayer.setUrl(_audioUrl);
    } catch (e) {
      debugPrint('加载音频失败: $e');
    }
  }

  void _updateAudioData() {
    final time = DateTime.now().millisecondsSinceEpoch / 1000.0;
    
    for (int i = 0; i < 64; i++) {
      if (_isPlaying) {
        final freq = (i / 64) * 6 + 1;
        final wave1 = sin(time * freq) * 0.4;
        final wave2 = sin(time * freq * 1.5 + pi / 3) * 0.3;
        final noise = (_random.nextDouble() - 0.5) * 0.15;
        final bassBoost = i < 16 ? 0.3 : 0;
        
        _audioData[i] = _audioData[i] * 0.7 + (wave1 + wave2 + noise + bassBoost) * 0.3;
      } else {
        _audioData[i] = _audioData[i] * 0.95;
      }
    }
    setState(() {});
  }

  @override
  void dispose() {
    _animController.dispose();
    _audioPlayer.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('音频响应')),
      body: Stack(
        children: [
          Container(
            color: Colors.black,
            child: CustomPaint(
              painter: AudioResponsivePainter(_audioData),
              size: Size.infinite,
            ),
          ),
          Positioned(
            bottom: 30,
            left: 20,
            right: 20,
            child: _buildPlayerControls(),
          ),
        ],
      ),
    );
  }
  
  Widget _buildPlayerControls() {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.black.withOpacity(0.7),
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          const Text('🎵 SoundHelix - Song 1', 
            style: TextStyle(color: Colors.white, fontSize: 14)),
          const SizedBox(height: 12),
          SliderTheme(
            data: SliderTheme.of(context).copyWith(
              activeTrackColor: Colors.cyan,
              inactiveTrackColor: Colors.grey.shade800,
              thumbColor: Colors.cyan,
            ),
            child: Slider(
              value: _duration.inMilliseconds > 0 
                  ? _position.inMilliseconds.toDouble().clamp(0, _duration.inMilliseconds.toDouble())
                  : 0,
              max: _duration.inMilliseconds > 0 
                  ? _duration.inMilliseconds.toDouble() 
                  : 1,
              onChanged: (value) {
                _audioPlayer.seek(Duration(milliseconds: value.toInt()));
              },
            ),
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(_formatDuration(_position), 
                style: const TextStyle(color: Colors.white70, fontSize: 12)),
              const SizedBox(width: 20),
              IconButton(
                icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow, 
                  color: Colors.cyan, size: 40),
                onPressed: () {
                  if (_isPlaying) {
                    _audioPlayer.pause();
                  } else {
                    _audioPlayer.play();
                  }
                },
              ),
              const SizedBox(width: 20),
              Text(_formatDuration(_duration), 
                style: const TextStyle(color: Colors.white70, fontSize: 12)),
            ],
          ),
        ],
      ),
    );
  }
  
  String _formatDuration(Duration d) {
    final minutes = d.inMinutes;
    final seconds = d.inSeconds.remainder(60);
    return '$minutes:${seconds.toString().padLeft(2, '0')}';
  }
}

class AudioResponsivePainter extends CustomPainter {
  final Float32List audioData;
  
  AudioResponsivePainter(this.audioData);
  
  @override
  void paint(Canvas canvas, Size size) {
    final curveCount = 8;
    final segmentSize = audioData.length ~/ curveCount;
    
    for (int i = 0; i < curveCount; i++) {
      double energy = 0;
      for (int j = 0; j < segmentSize; j++) {
        energy += audioData[i * segmentSize + j].abs();
      }
      energy /= segmentSize;
      
      final y = size.height * (0.2 + i * 0.08);
      final amplitude = energy * 100;
      
      final hue = i / curveCount * 360;
      final color = HSVColor.fromAHSV(0.7, hue, 1, 1).toColor();
      
      final path = Path();
      path.moveTo(0, y);
      path.cubicTo(
        size.width * 0.33, y - amplitude,
        size.width * 0.66, y + amplitude,
        size.width, y,
      );
      
      final strokePaint = Paint()
        ..color = color
        ..strokeWidth = 3
        ..style = PaintingStyle.stroke;
      
      canvas.drawPath(path, strokePaint);
    }
  }
  
  @override
  bool shouldRepaint(covariant AudioResponsivePainter oldDelegate) => audioData != oldDelegate.audioData;
}

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

  @override
  State<FullFluidDemo> createState() => _FullFluidDemoState();
}

class _FullFluidDemoState extends State<FullFluidDemo> with TickerProviderStateMixin {
  late AnimationController _animController;
  late AudioPlayer _audioPlayer;
  final Random _random = Random();
  Float32List _audioData = Float32List(128);
  bool _isPlaying = false;
  Duration _position = Duration.zero;
  Duration _duration = Duration.zero;
  
  static const String _audioUrl = 
      'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3';
  
  @override
  void initState() {
    super.initState();
    _initAudio();
    _animController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 16),
    )..repeat();
    _animController.addListener(_update);
  }
  
  Future<void> _initAudio() async {
    _audioPlayer = AudioPlayer();
    
    final session = await AudioSession.instance;
    await session.configure(const AudioSessionConfiguration.music());
    
    _audioPlayer.playerStateStream.listen((state) {
      setState(() {
        _isPlaying = state.playing;
      });
    });
    
    _audioPlayer.positionStream.listen((position) {
      setState(() {
        _position = position;
      });
    });
    
    _audioPlayer.durationStream.listen((duration) {
      setState(() {
        _duration = duration ?? Duration.zero;
      });
    });
    
    try {
      await _audioPlayer.setUrl(_audioUrl);
    } catch (e) {
      debugPrint('加载音频失败: $e');
    }
  }

  void _update() {
    final time = DateTime.now().millisecondsSinceEpoch / 1000.0;
    
    for (int i = 0; i < 128; i++) {
      if (_isPlaying) {
        final freq = (i / 128) * 8 + 1;
        final wave1 = sin(time * freq) * 0.4;
        final wave2 = sin(time * freq * 1.5 + pi / 3) * 0.3;
        final wave3 = cos(time * freq * 0.5 + pi / 6) * 0.2;
        final noise = (_random.nextDouble() - 0.5) * 0.15;
        final bassBoost = i < 32 ? 0.3 : 0;
        
        _audioData[i] = _audioData[i] * 0.85 + 
            (wave1 + wave2 + wave3 + noise + bassBoost) * 0.15;
      } else {
        _audioData[i] = _audioData[i] * 0.95;
      }
    }
    setState(() {});
  }

  @override
  void dispose() {
    _animController.dispose();
    _audioPlayer.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          Container(
            decoration: const BoxDecoration(
              gradient: LinearGradient(
                begin: Alignment.topCenter,
                end: Alignment.bottomCenter,
                colors: [Colors.black, Colors.indigo, Colors.black],
              ),
            ),
            child: CustomPaint(
              painter: FullFluidPainter(
                audioData: _audioData,
                time: DateTime.now().millisecondsSinceEpoch / 1000.0,
                isPlaying: _isPlaying,
              ),
              size: Size.infinite,
            ),
          ),
          Positioned(
            top: 40,
            left: 20,
            child: SafeArea(
              child: IconButton(
                icon: const Icon(Icons.arrow_back, color: Colors.white),
                onPressed: () => Navigator.pop(context),
              ),
            ),
          ),
          Positioned(
            bottom: 40,
            left: 20,
            right: 20,
            child: _buildPlayerControls(),
          ),
        ],
      ),
    );
  }
  
  Widget _buildPlayerControls() {
    return Container(
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: Colors.black.withOpacity(0.7),
        borderRadius: BorderRadius.circular(20),
        border: Border.all(color: Colors.cyan.withOpacity(0.3), width: 1),
        boxShadow: [
          BoxShadow(
            color: Colors.cyan.withOpacity(0.1),
            blurRadius: 20,
            spreadRadius: 5,
          ),
        ],
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          const Text(
            '🎵 贝塞尔流体律动',
            style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
          ),
          const SizedBox(height: 4),
          const Text('SoundHelix - Song 1', 
            style: TextStyle(color: Colors.white70, fontSize: 14)),
          const SizedBox(height: 16),
          SliderTheme(
            data: SliderTheme.of(context).copyWith(
              activeTrackColor: Colors.cyan,
              inactiveTrackColor: Colors.grey.shade800,
              thumbColor: Colors.cyan,
              overlayColor: Colors.cyan.withOpacity(0.2),
            ),
            child: Slider(
              value: _duration.inMilliseconds > 0 
                  ? _position.inMilliseconds.toDouble().clamp(0, _duration.inMilliseconds.toDouble())
                  : 0,
              max: _duration.inMilliseconds > 0 
                  ? _duration.inMilliseconds.toDouble() 
                  : 1,
              onChanged: (value) {
                _audioPlayer.seek(Duration(milliseconds: value.toInt()));
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 8),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(_formatDuration(_position), 
                  style: const TextStyle(color: Colors.white70, fontSize: 12)),
                Text(_formatDuration(_duration), 
                  style: const TextStyle(color: Colors.white70, fontSize: 12)),
              ],
            ),
          ),
          const SizedBox(height: 12),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              IconButton(
                icon: const Icon(Icons.replay_10, color: Colors.white70, size: 28),
                onPressed: () {
                  final newPos = _position - const Duration(seconds: 10);
                  _audioPlayer.seek(newPos.isNegative ? Duration.zero : newPos);
                },
              ),
              const SizedBox(width: 20),
              Container(
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  gradient: LinearGradient(
                    begin: Alignment.topLeft,
                    end: Alignment.bottomRight,
                    colors: [Colors.cyan.shade400, Colors.cyan.shade700],
                  ),
                  boxShadow: [
                    BoxShadow(color: Colors.cyan.withOpacity(0.4), blurRadius: 15, spreadRadius: 2),
                  ],
                ),
                child: IconButton(
                  icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 36),
                  onPressed: () {
                    if (_isPlaying) {
                      _audioPlayer.pause();
                    } else {
                      _audioPlayer.play();
                    }
                  },
                ),
              ),
              const SizedBox(width: 20),
              IconButton(
                icon: const Icon(Icons.forward_10, color: Colors.white70, size: 28),
                onPressed: () {
                  final newPos = _position + const Duration(seconds: 10);
                  _audioPlayer.seek(newPos > _duration ? _duration : newPos);
                },
              ),
            ],
          ),
        ],
      ),
    );
  }
  
  String _formatDuration(Duration d) {
    final minutes = d.inMinutes;
    final seconds = d.inSeconds.remainder(60);
    return '$minutes:${seconds.toString().padLeft(2, '0')}';
  }
}

class FullFluidPainter extends CustomPainter {
  final Float32List audioData;
  final double time;
  final bool isPlaying;
  
  FullFluidPainter({required this.audioData, required this.time, required this.isPlaying});
  
  @override
  void paint(Canvas canvas, Size size) {
    final curveCount = 12;
    final segmentSize = audioData.length ~/ curveCount;
    
    for (int i = 0; i < curveCount; i++) {
      double energy = 0;
      for (int j = 0; j < segmentSize; j++) {
        energy += audioData[i * segmentSize + j].abs();
      }
      energy /= segmentSize;
      
      final baseY = size.height * (0.15 + i * 0.06);
      final wave1 = sin(time * 2 + i * 0.5) * 30 * energy;
      final wave2 = sin(time * 3 + i * 0.3) * 20 * energy;
      
      final hue = (i / curveCount * 180 + time * 20) % 360;
      final color = HSVColor.fromAHSV(0.6, hue, 0.8, 1).toColor();
      
      final path = Path();
      path.moveTo(-10, baseY + wave1);
      path.cubicTo(
        size.width * 0.25, baseY - 40 * energy + wave2,
        size.width * 0.75, baseY + 40 * energy + wave1,
        size.width + 10, baseY + wave2,
      );
      path.lineTo(size.width + 10, size.height + 10);
      path.lineTo(-10, size.height + 10);
      path.close();
      
      final gradient = LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter,
        colors: [
          color,
          color.withOpacity(0.3),
        ],
      );
      
      final paint = Paint()
        ..shader = gradient.createShader(Rect.fromLTWH(0, 0, size.width, size.height))
        ..style = PaintingStyle.fill;
      
      canvas.drawPath(path, paint);
      
      final strokePaint = Paint()
        ..color = color.withOpacity(0.8)
        ..strokeWidth = 1.5
        ..style = PaintingStyle.stroke;
      
      final strokePath = Path();
      strokePath.moveTo(-10, baseY + wave1);
      strokePath.cubicTo(
        size.width * 0.25, baseY - 40 * energy + wave2,
        size.width * 0.75, baseY + 40 * energy + wave1,
        size.width + 10, baseY + wave2,
      );
      
      canvas.drawPath(strokePath, strokePaint);
    }
    
    if (isPlaying) {
      _drawParticles(canvas, size);
    }
  }
  
  void _drawParticles(Canvas canvas, Size size) {
    final random = Random(time.toInt());
    final particleCount = 25;
    
    for (int i = 0; i < particleCount; i++) {
      final x = random.nextDouble() * size.width;
      final y = random.nextDouble() * size.height;
      final radius = random.nextDouble() * 3 + 1;
      
      final hue = (i / particleCount * 360 + time * 30) % 360;
      final color = HSVColor.fromAHSV(0.6, hue, 1, 1).toColor();
      
      final paint = Paint()
        ..color = color
        ..style = PaintingStyle.fill
        ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3);
      
      canvas.drawCircle(Offset(x, y), radius, paint);
    }
  }
  
  @override
  bool shouldRepaint(covariant FullFluidPainter oldDelegate) => true;
}

📝 六、总结

本篇文章深入探讨了三阶贝塞尔曲线在音乐可视化中的应用,从数学原理到代码实现,涵盖了以下核心内容:

✅ 核心知识点回顾

知识点 说明
📐 贝塞尔曲线数学原理 理解参数方程和控制点的作用
🔄 德卡斯特里奥算法 递归分割计算曲线点
🌬️ "呼吸感"设计 周期性、缓动变化、有机性、响应性
🎵 音频驱动变形 使用音频数据驱动控制点移动
🎨 Flutter 绘制技术 CustomPainter 和 Canvas 的使用

⭐ 最佳实践要点

  • ✅ 使用三阶贝塞尔曲线平衡灵活性和计算效率
  • ✅ 自适应采样保证绘制质量
  • ✅ 多曲线组合创造丰富效果
  • ✅ 渐变填充增强视觉层次

🚀 进阶方向

  • 🔮 结合着色器实现 GPU 加速
  • ✨ 添加粒子效果增强动态感
  • 👆 实现用户交互控制
  • ⚡ 优化性能以支持更多曲线

相关推荐
2501_921930832 小时前
进阶实战 Flutter for OpenHarmony:响应式状态机系统 - 复杂状态流转实现
flutter
松叶似针2 小时前
Flutter三方库适配OpenHarmony【doc_text】— Word 文档格式深度科普:从 OLE2 到 OOXML
flutter·harmonyos
空白诗2 小时前
Harmony Flutter 跨平台开发实战:鸿蒙与音乐律动艺术、粒子系统与流体模拟:动态粒子的视觉盛宴
flutter·harmonyos
空白诗2 小时前
Harmony Flutter 跨平台开发实战:鸿蒙与音乐律动艺术、混沌理论与奇异吸引子:从洛伦兹到音乐的动态艺术
flutter·harmonyos
2501_921930833 小时前
进阶实战 Flutter for OpenHarmony:高性能列表虚拟化系统 - 大数据量渲染优化实现
flutter
2501_921930834 小时前
进阶实战 Flutter for OpenHarmony:自定义渲染引擎系统 - RenderObject 底层绘制实现
flutter
早點睡3904 小时前
Harmony Flutter 跨平台开发实战:鸿蒙与音乐律动艺术、粒子物理引力场:万有引力与排斥逻辑
flutter·华为·harmonyos
九狼4 小时前
Riverpod 2.0 代码生成与依赖注入
flutter·设计模式·github
空白诗4 小时前
Harmony Flutter 跨平台开发实战:鸿蒙与音乐律动艺术、傅里叶变换与频谱:从时域到频域的视觉翻译
flutter