用Flutter的Canvas来自己绘制柱状频谱图

  • Post author:
  • Post category:其他


前言

关于Flutter,之前写了两篇文章,第一篇

Flutter如何和Native通信-Android视角

简单说了一下如何使用Flutter和Native的通信通道:Platform Channels;第二篇

Flutter插件(Plugin)开发 – Android视角

讲了Flutter插件开发的过程,文中我们把Android

MediaPlayer

的部分功能包装成了个Flutter插件。并且实现了个使用这个插件的低配版音乐播放器。

为了继续学习Flutter开发,顺便也想看看Flutter app的性能表现如何,我在这个低配版音乐播放器上加了个音乐柱状频谱图。这篇文章会讲讲具体怎么来做这件事。所有代码均可从

Github

获取。先上张动图大家感受一下。

动图里那些动来动去的上红下绿的柱子就是当前正在播放的音乐的频谱,从左至右频率依次升高。接下来我们来实现这样的效果吧,首先还是看看Native端怎么做。

Native(Android)端

频谱数据是通过Android自带的

Visualizer

获取的。而要使用

Visualizer

首先要取得

android.permission.RECORD_AUDIO

权限。我们要先处理一下插件中动态请求权限的情况。

请求动态权限

首先在插件的

AndroidManifest.xml

中加入

android.permission.RECORD_AUDIO

权限。

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="io.github.zhangjianli.fluttermusicplugin">
    <uses-permission android:name="android.permission.RECORD_AUDIO"/>
</manifest>
复制代码

然后在

FlutterMusicPlugin

的构造函数中检查下权限(不建议这样做)。

private FlutterMusicPlugin(Activity activity) {
        mActivity = activity;
        if (mActivity.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
            // Permission is not granted
            mActivity.requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO}, PERMISSIONS_REQUEST_RECORD_AUDIO);
        }
    }
复制代码

动态权限的回调在

registerWith

中注册。

public static void registerWith(Registrar registrar) {
        final FlutterMusicPlugin plugin = new FlutterMusicPlugin(registrar.activity());
        ...
        registrar.addActivityResultListener(plugin);
        ...
    }
复制代码

权限回调的处理,简单起见,这里我们直接退出app。

 @Override
    public boolean onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) {
        switch (requestCode) {
            case PERMISSIONS_REQUEST_RECORD_AUDIO :
                if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    return true;
                } else {
                    mActivity.finish();
                    return false;
                }
            default:
                return  false;
        }
    }
复制代码

创建频谱通道

权限的问题处理好了。接下来我们要做的就是在本地播放音乐的同时使用

Visualizer

来获取频谱数据。

 mMediaPlayer.prepare();
 mVisualizer = new Visualizer(mMediaPlayer.getAudioSessionId());
 mVisualizer.setCaptureSize(Visualizer.getCaptureSizeRange()[0]);
 mVisualizer.setDataCaptureListener(new Visualizer.OnDataCaptureListener() {
        public void onWaveFormDataCapture(Visualizer visualizer, byte[] bytes, int samplingRate) {
        public void onFftDataCapture(Visualizer visualizer, byte[] bytes, int samplingRate) {
            // 得到频谱数据
            byte[] spectrum = new byte[bytes.length / 2];
            // 转换为幅度
            for (int i = 0; i < spectrum.length; i++) {
                Double magnitude = Math.hypot(bytes[2*i], bytes[2*i+1]);
                if (magnitude < 0) {
                    spectrum[i] = 0;
                } else if (magnitude > 127) {
                    spectrum[i] = 127 & 0xFF;
                } else {
                    spectrum[i] = magnitude.byteValue();
                }
            }
            //通过EventChannel发送给Flutter
            mSpectrumSink.success(spectrum);
        }
    }, Visualizer.getMaxCaptureRate()/2, false, true);
    mVisualizer.setEnabled(true);
    mMediaPlayer.start();
复制代码

获取到的频谱数据转换为幅度数据以后,通过EventChannel发送给Flutter。 EventChannel的使用可参考

Flutter如何和Native通信-Android视角

。这里不再重复。

Flutter端

频谱柱状图的显示我们做成了一个Widget,名字叫

Visualizer

。由于频谱是不停变化的,所以它是一个

StatefulWidget

class Visualizer extends StatefulWidget {
  @override
  VisualizerState createState() => VisualizerState();
}

class VisualizerState extends State<Visualizer> {
  // 频谱数据
  Uint8List _spectrum;

  @override
  void initState() {
    super.initState();
    // connect to native channels
    FlutterMusicPlugin.listenSpectrum(_onSpectrum, _onSpectrumError);
  }
  
  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: VisualizerPainter(_spectrum)
    );
  }
}
复制代码

显然用现有的组件我们不太好拼出来频谱的柱状图,所以需要自己来画出来了。在Android中我们会去自定一个

View

然后重写

onDraw

来画,在Flutter中用

CustomPaint

也能达到同样的效果。创建

CustomPaint

的时候需要传入一个

painter

参数。具体在画布上画些什么东西就是由这个

painter

来决定的。所以我们自定义了一个

VisualizerPainter

来画频谱柱状图。

class VisualizerPainter extends CustomPainter {
  // 频谱数据
  final Uint8List _spectrum;
  VisualizerPainter(this._spectrum);

  @override
  void paint(Canvas canvas, Size size) {
    // 先画个黑色的背景
    var rect = Offset.zero & size;
    canvas.drawRect(
      rect,
      Paint()..color = Color(0xFF000000)
    );
    // 给个好看的颜色
    LinearGradient gradient = LinearGradient(colors: [const Color(0xFF33FF33), const Color(0xFFFF0033)], begin: Alignment.bottomCenter, end: Alignment.topCenter);
    // 每个柱子的宽度
    double columnWidth = size.width / COLUMNS_COUNT;
    // 幅度比例
    double step = size.height / 127;
    // 挨个画频谱柱子
    for (int i=0; i<COLUMNS_COUNT; i++) {
      double volume = 2.0;
      if (_spectrum != null && i < _spectrum.length) {
        volume = _spectrum[i] * step + 2;
      }
      Rect column = Rect.fromLTRB(columnWidth*i, size.height-volume, columnWidth*i+columnWidth - 1, size.height);
      canvas.drawRect(
          column,
          Paint()..shader = gradient.createShader(column)
      );
    }

  }

  @override
  // 只有在频谱数据发生变化的时候才重绘
  bool shouldRepaint(VisualizerPainter oldDelegate) =>oldDelegate._spectrum != _spectrum;
}
复制代码

当有新的频谱数据传过来的时候,调用

setState

触发重绘

void _onSpectrum(Object event) {
    setState(() {
        _spectrum = event;
    });
  }
复制代码

最后在

main.dart

里把

Visualizer

加上就行了。来看看效果

emm…..频谱显示是有了,但是给人的感觉比较突兀。缺少像喷泉那样急速升高以后缓缓回落的质感。我们来改进一下,给频谱柱子加个下降的动画吧。

加个动画

给频谱柱子加个回落的动画需要知道每次UI刷新的信号,也就是vsync信号,如果刷新率是60fps的话大概就是16ms一个vsync信号。Flutter中的

Ticker

可以提供这个vsync信号。

Tiker

启动以后会在每次vsync信号到来的时候回调你设置的callback。本来我们可以直接使用

Tiker

,但是直接使用的话管理起来比较麻烦。还好Flutter有个

AnimationController



AnimationController

内部包含了一个

Tiker

,并且提供了其他的一些控制逻辑。用起来比较方便。

// 用SingleTickerProviderStateMixin扩展VisualizerState
class VisualizerState extends State<Visualizer> with SingleTickerProviderStateMixin {
  AnimationController _controller;
  @override
  void initState() {
    super.initState();
    // 创建个AnimationController 时长200ms。
    _controller = AnimationController(duration: Duration(milliseconds: 200), vsync: this);
    // 设个callabck
    _controller.addListener(_onTick);
  }
复制代码

创建

AnimationController

的时候需要传入

vsync

参数。这需要State自身扩展

SingleTickerProviderStateMixin

。然后把自己传进去就好了。200ms的时长是应为频谱数据基本上会每隔100ms从Native传过来一波。200ms的话保障动画会在下次新频谱数据过来之前会持续播放,并且在音频停止以后不会一直无效的调用回调。在

_onTick

回调里面把每个频谱幅度减1制造回落的效果,调用

setState

触发

Visualizer

重绘。

void _onTick() {
    setState(() {
     for (int i=0; i<COLUMNS_COUNT; i++) {
         _spectrum[i] = (_spectrum[i] - 1).clamp(0, 127);
     }
    });
  }
复制代码

最后重新热重载一下,并且打开Performance Overlay看一下性能。具体性能检测工具怎么用可以去看

官方文档

手机是Nexus 5真机(至少3,4年前的手机了),在Debug模式下, UI基本上能稳定在60fps, GPU稳定在40fps左右。动图中帧率比较低是受录屏的影响。如果是更好的手机,并且用release模式的话性能应该会更好。可以说Flutter性能完全可以和原生app媲美了。

总结

本文主要介绍了如何使用Flutter的

CustomPainter

自己绘制音乐柱状频谱图。当然你也可以用

CustomPainter

来绘制任何其他图形(比如各种图表)。然后我们又用

AnimationController

来美化了一下频谱图,让频谱的表现更加平滑自然。最后我们使用Flutter提供的性能检测工具Performance Overlay观察了一下Flutter app的性能。总的感想就有两点:

  • Flutter app开发确实比较方便。
  • Flutter app性能确实可以与原生app比肩。

那么,你还在等什么,赶快投身Flutter开发吧。