用Flutter的Canvas來自己繪製柱狀頻譜圖

ad6623發表於2018-07-26

前言

關於Flutter,之前寫了兩篇文章,第一篇Flutter如何和Native通訊-Android視角簡單說了一下如何使用Flutter和Native的通訊通道:Platform Channels;第二篇Flutter外掛(Plugin)開發 - Android視角講了Flutter外掛開發的過程,文中我們把Android MediaPlayer的部分功能包裝成了個Flutter外掛。並且實現了個使用這個外掛的低配版音樂播放器。

為了繼續學習Flutter開發,順便也想看看Flutter app的效能表現如何,我在這個低配版音樂播放器上加了個音樂柱狀頻譜圖。這篇文章會講講具體怎麼來做這件事。所有程式碼均可從Github獲取。先上張動圖大家感受一下。

用Canvas自己畫音樂柱狀頻譜圖

動圖裡那些動來動去的上紅下綠的柱子就是當前正在播放的音樂的頻譜,從左至右頻率依次升高。接下來我們來實現這樣的效果吧,首先還是看看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加上就行了。來看看效果

bad
emm.....頻譜顯示是有了,但是給人的感覺比較突兀。缺少像噴泉那樣急速升高以後緩緩回落的質感。我們來改進一下,給頻譜柱子加個下降的動畫吧。

加個動畫

給頻譜柱子加個回落的動畫需要知道每次UI重新整理的訊號,也就是vsync訊號,如果重新整理率是60fps的話大概就是16ms一個vsync訊號。Flutter中的Ticker可以提供這個vsync訊號。Tiker啟動以後會在每次vsync訊號到來的時候回撥你設定的callback。本來我們可以直接使用Tiker,但是直接使用的話管理起來比較麻煩。還好Flutter有個AnimationControllerAnimationController內部包含了一個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看一下效能。具體效能檢測工具怎麼用可以去看官方文件

Performance
手機是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開發吧。

相關文章