Flutter外掛開發---iOS篇

chonglingliu發表於2021-05-10

Flutter的願景是一般的開發者只需要開發Flutter程式碼就能實現跨平臺的應用,官方提供了一些外掛,也有很多可以可以直接拿來使用的第三方外掛。

但是現實是現實,例如當遇到定製化的功能時,編寫外掛是不可避免的。譬如我們有一個自定義協議的藍芽功能,這個功能在Flutter中就不可能直接拿來使用了,需要編寫外掛讓Flutter進行呼叫。本文我們將來看看Flutter外掛是如何實現的。

前言

本文我們用Flutter來仿寫網易雲音樂的播放頁面的功能,其中音樂的播放音樂的暫停快進音樂的時長獲取音樂播放的進度等功能我們需要用原生程式碼編寫外掛來實現。

效果如圖所示

提示:本文用音樂播放器的外掛只是為了提供一個編寫Flutter外掛的思路和方法,當需要自己編寫外掛的時候可以方便的來實現。播放音視訊的Flutter外掛已經有一些優秀的三方庫已經實現了。

說明:

  1. 由於是音訊播放,我製作GIF的時候沒法體現音樂元素,所以音樂只能我自己獨自欣賞了,哈哈~~
  2. 本文先只介紹iOS的外掛製作,下篇文章我們再來介紹Android的外掛製作。

架構概覽

架構圖

我們從上面的官方架構圖可以看出,FlutterNative程式碼是通過MethodChannel進行通訊的。

Flutter端向iOS端傳送訊息

Flutter端的程式碼

  • 建立一個播放器類AudioPlayer, 然後定義為單例模式
class AudioPlayer {
// 單例
  factory AudioPlayer() => _getInstance();
  static AudioPlayer get instance => _getInstance();
  static AudioPlayer _instance;
  AudioPlayer._internal() {}

  static AudioPlayer _getInstance() {
    if (_instance == null) {
      _instance = new AudioPlayer._internal();
    }
    return _instance;
  }
}
複製程式碼
  • 建立播放器的MethodChannel
class AudioPlayer {
    static final channel = const MethodChannel("netmusic.com/audio_player");
}
複製程式碼

MethodChannel名字要有意義,其組成遵循"域名"+"/"+"功能",隨意寫就顯得不夠專業。

  • 通過MethodChannelinvokeMethod實現播放音樂
/// 播放
  Future<int> play() async {
    final result = await channel.invokeMethod("play", {'url': audioUrl});
    return result ?? 0;
  }
複製程式碼
  1. play就是方法名, {'url': audioUrl}就是引數
  2. invokeMethod是非同步的,所以返回值需要用Future包裹。
  • 通過MethodChannelinvokeMethod實現暫停音樂
/// 暫停
Future<int> pause() async {
    final result = await channel.invokeMethod("pause", {'url': audioUrl});
    return result ?? 0;
}
複製程式碼
  • 通過MethodChannelinvokeMethod實現繼續播放音樂
/// 繼續播放
Future<int> resume() async {
    final result = await channel.invokeMethod("resume", {'url': audioUrl});
    return result ?? 0;
}
複製程式碼
  • 通過MethodChannelinvokeMethod實現拖動播放位置
/// 拖動播放位置
Future<int> seek(int time) async {
    final result = await channel.invokeMethod("seek", {
      'position': time,
    });
    return result ?? 0;
}
複製程式碼

iOS端的程式碼

前提:需要用Xcode開啟iOS專案,這是開始編寫的基礎。

  • 建立一個播放器類PlayerWrapper
class PlayerWrapper: NSObject {
    
    var vc: FlutterViewController
    var channel: FlutterMethodChannel
    var player: AVPlayer?
    
}
複製程式碼
  • AppDelegate中初始化PlayerWrapper,並將FlutterViewController作為初始化引數。
@objc class AppDelegate: FlutterAppDelegate {
    
    // 持有播放器
    var playerWrapper: PlayerWrapper?
    
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        
        // 初始化播放器
        let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
        playerWrapper = PlayerWrapper(vc: controller)
        
        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

複製程式碼
  1. FlutterAppDelegate的根檢視就是一個FlutterViewController,這個我們在以前的文章中有介紹;
  2. FlutterViewController中有一個FlutterBinaryMessenger,建立FlutterMethodChannel時需要,所以將其傳入PlayerWrapper
  • 建立播放器的FlutterMethodChannel
class PlayerWrapper: NSObject {
    init(vc: FlutterViewController) {
        self.vc = vc
        channel = FlutterMethodChannel(name: "netmusic.com/audio_player", binaryMessenger: vc.binaryMessenger)
        super.init()
    }
}
複製程式碼
  1. name的值必須和Flutter中的對應,否則是沒法通訊的;
  2. binaryMessenger就使用FlutterViewControllerFlutterBinaryMessenger,前面提到過。
  • 接收Flutter端的呼叫,然後回撥Flutter端播放進度和結果等。

由於是被動接收,所以可以想象的實現是註冊一個回撥函式,接收Flutter端的呼叫方法和引數。

init(vc: FlutterViewController) {
    //...
    channel.setMethodCallHandler(handleFlutterMessage);
}

// 從Flutter傳過來的方法
public func handleFlutterMessage(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    // 1. 獲取方法名和引數
    let method = call.method
    let args = call.arguments as? [String: Any]
        
    if method == "play" {
        // 2.1 確保有url引數
        guard let url = args?["url"] as! String? else {
            result(0)
            return
        }
        player?.pause()
        // 2.2 確保有url引數正確
        guard let audioURL = URL.init(string: url) else {
            result(0)
            return
        }
        // 2.3 根據url初始化播放內容,然後開始進行播放
        let asset = AVAsset.init(url: audioURL)
        let item = AVPlayerItem.init(asset: asset);
        player = AVPlayer(playerItem: item);
        player?.play();
        
        // 2.4 定時檢測播放進度    
        player?.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: 1), queue: nil, using: { [weak self] (time) in
            // *********回撥Flutter當前播放進度*********
            self?.channel.invokeMethod("onPosition", arguments: ["value": time.value / Int64(time.timescale)])
            })
            
        keyVakueObservation?.invalidate()
        // 2.5 監測播放狀態
        keyVakueObservation = item.observe(\AVPlayerItem.status) { [weak self] (playerItem, change) in
            let status = playerItem.status
            if status == .readyToPlay {
                // *********回撥Flutter當前播放內容的總長度*********
                if let time = self?.player?.currentItem?.asset.duration {
                    self?.channel.invokeMethod("onDuration", arguments: ["value": time.value / Int64(time.timescale)])
            }
        } else if status == .failed {
            // *********回撥Flutter當前播放出現錯誤*********
            self?.channel.invokeMethod("onError", arguments: ["value": "pley failed"])
            }
        }
        
        // 2.6 監測播放完成
        notificationObservation = NotificationCenter.default.addObserver(
            forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
            object: item,**
            queue: nil
        ) {
            [weak self] (notification) in
            self?.channel.invokeMethod("onComplete", arguments: [])
        }**
            
        result(1)
            
    } else if method == "pause" || method == "stop" {
        // 3 暫停
        player?.pause()
        result(1)
    } else if method == "resume" {
        // 4 繼續播放
        player?.play()
        result(1)
    } else if method == "seek" {
        
        guard let position = args?["position"] as! Int? else {
            result(0)
            return
        }
        // 4 拖動到某處進行播放
        let seekTime: CMTime = CMTimeMake(value: Int64(position), timescale: 1)
        player?.seek(to: seekTime);
    }
}
複製程式碼
  1. handleFlutterMessage這個回撥函式有兩個引數:FlutterMethodCall接收Flutter傳過來的方法名method和引數arguments, FlutterResult可以返回撥用的結果,例如result(1)就給Flutter返回了1這個結果。
  2. 獲取到FlutterMethodCall的方法名和引數後就可以進行處理了,我們以play為例:
  • 根據url初始化播放內容,然後開始進行播放;
  • 通過player.addPeriodicTimeObserver方法檢測播放進度,然後通過FlutterMethodChannelinvokeMethod方法傳遞當前的進度給Flutter端,方法名是onPosition,引數是當前進度;
  • 後面還有一列邏輯:例如監聽播放狀態,監聽播放完成等。

目前為止,iOS端的程式碼完成了。接下來就是Flutter端接收iOS端的方法和引數了。

Flutter端接收iOS端傳送的訊息

iOS端向Flutter端傳送了onPosition(當前播放進度),onComplete(播放完成),onDuration(當前歌曲的總長度)和onError(播放出現錯誤)等幾個方法呼叫。

  • Flutter端註冊回撥
AudioPlayer._internal() {
    channel.setMethodCallHandler(nativePlatformCallHandler);
}

/// Native主動呼叫的方法
Future<void> nativePlatformCallHandler(MethodCall call) async {
    try {
      // 獲取引數
      final callArgs = call.arguments as Map<dynamic, dynamic>;
      print('nativePlatformCallHandler call ${call.method} $callArgs');
      switch (call.method) {
        case 'onPosition':
          final time = callArgs['value'] as int;
          _currentPlayTime = time;
          _currentPlayTimeController.add(_currentPlayTime);
          break;
        case 'onComplete':
          this.updatePlayerState(PlayerState.COMPLETED);
          break;
        case 'onDuration':
          final time = callArgs['value'] as int;
          _totalPlayTime = time;
          _totalPlayTimeController.add(totalPlayTime);
          break;
        case 'onError':
          final error = callArgs['value'] as String;
          this.updatePlayerState(PlayerState.STOPPED);
          _errorController.add(error);
          break;
      }
    } catch (ex) {
      print('Unexpected error: $ex');
    }
}
複製程式碼
  1. 註冊回撥也是使用setMethodCallHandler方法,MethodCall對應的也包含方法名和引數;
  2. 獲取到對應的資料後Flutter就可進行資料的展示了。
  • Flutter端對資料的更新

我們以onDuration(當前歌曲的總長度)為例進行介紹。

class AudioPlayer {
    
    // 1. 記錄下總時間
    int _totalPlayTime = 0;
    int get totalPlayTime => _totalPlayTime;
    
    // 2. 代表歌曲時長的流
    final StreamController<int> _totalPlayTimeController =
      StreamController<int>.broadcast();
    Stream<int> get onTotalTimeChanged => _totalPlayTimeController.stream;
    
    Future<void> nativePlatformCallHandler(MethodCall call) async {
        try {
          final callArgs = call.arguments as Map<dynamic, dynamic>;
          print('nativePlatformCallHandler call ${call.method} $callArgs');
          switch (call.method) {
            // 3. 記錄下總時間和推送更新
            case 'onDuration':
              final time = callArgs['value'] as int;
              _totalPlayTime = time;
              _totalPlayTimeController.add(totalPlayTime);
              break;
          }
        } catch (ex) {
          print('Unexpected error: $ex');
        }
    }
}
複製程式碼
  1. _totalPlayTime記錄下總播放時長;
  2. _totalPlayTimeController是總播放時長的流,當呼叫add方法時,onTotalTimeChanged的監聽者就能收到新的值;
  • StreamBuilder監聽流的資料
StreamBuilder(
    initialData: "00:00",
    stream: AudioPlayer().onTotalTimeChanged,
    builder: (context, snapshot) {
        if (!snapshot.hasData)
            return Text(
                "00:00",
                style: TextStyle(color: Colors.white70),
            );
        return Text(
            AudioPlayer().totalPlayTimeStr,
            style: TextStyle(color: Colors.white70),
            );
        },
),
複製程式碼

監聽AudioPlayer().onTotalTimeChanged的資料變化,然後最新的值展示在Text上。

程式碼

audio_player.dart
import 'dart:async';

import 'package:flutter/services.dart';
import 'package:netmusic_flutter/music_item.dart';

class AudioPlayer {
  // 定義一個MethodChannel
  static final channel = const MethodChannel("netmusic.com/audio_player");

  // 單例
  factory AudioPlayer() => _getInstance();
  static AudioPlayer get instance => _getInstance();
  static AudioPlayer _instance;
  AudioPlayer._internal() {
    // 初始化
    channel.setMethodCallHandler(nativePlatformCallHandler);
  }

  static AudioPlayer _getInstance() {
    if (_instance == null) {
      _instance = new AudioPlayer._internal();
    }
    return _instance;
  }

  // 播放狀態
  PlayerState _playerState = PlayerState.STOPPED;
  PlayerState get playerState => _playerState;

  // 時間
  int _totalPlayTime = 0;
  int _currentPlayTime = 0;
  int get totalPlayTime => _totalPlayTime;
  int get currentPlayTime => _currentPlayTime;
  String get totalPlayTimeStr => formatTime(_totalPlayTime);
  String get currentPlayTimeStr => formatTime(_currentPlayTime);

  // 歌曲
  MusicItem _item;
  set item(MusicItem item) {
    _item = item;
  }

  String get audioUrl {
    return _item != null
        ? "https://music.163.com/song/media/outer/url?id=${_item.id}.mp3"
        : "";
  }

  Future<int> togglePlay() async {
    if (_playerState == PlayerState.PLAYING) {
      return pause();
    } else {
      return play();
    }
  }

  /// 播放
  Future<int> play() async {
    if (_item == null) return 0;
    // 如果是停止狀態
    if (_playerState == PlayerState.STOPPED ||
        _playerState == PlayerState.COMPLETED) {
      // 更新狀態
      this.updatePlayerState(PlayerState.PLAYING);
      final result = await channel.invokeMethod("play", {'url': audioUrl});
      return result ?? 0;
    } else if (_playerState == PlayerState.PAUSED) {
      return resume();
    }
    return 0;
  }

  /// 繼續播放
  Future<int> resume() async {
    // 更新狀態
    this.updatePlayerState(PlayerState.PLAYING);
    final result = await channel.invokeMethod("resume", {'url': audioUrl});
    return result ?? 0;
  }

  /// 暫停
  Future<int> pause() async {
    // 更新狀態
    this.updatePlayerState(PlayerState.PAUSED);
    final result = await channel.invokeMethod("pause", {'url': audioUrl});
    return result ?? 0;
  }

  /// 停止
  Future<int> stop() async {
    // 更新狀態
    this.updatePlayerState(PlayerState.STOPPED);
    final result = await channel.invokeMethod("stop");
    return result ?? 0;
  }

  /// 播放
  Future<int> seek(int time) async {
    // 更新狀態
    this.updatePlayerState(PlayerState.PLAYING);
    final result = await channel.invokeMethod("seek", {
      'position': time,
    });
    return result ?? 0;
  }

  /// Native主動呼叫的方法
  Future<void> nativePlatformCallHandler(MethodCall call) async {
    try {
      // 獲取引數
      final callArgs = call.arguments as Map<dynamic, dynamic>;
      print('nativePlatformCallHandler call ${call.method} $callArgs');
      switch (call.method) {
        case 'onPosition':
          final time = callArgs['value'] as int;
          _currentPlayTime = time;
          _currentPlayTimeController.add(_currentPlayTime);
          break;
        case 'onComplete':
          this.updatePlayerState(PlayerState.COMPLETED);
          break;
        case 'onDuration':
          final time = callArgs['value'] as int;
          _totalPlayTime = time;
          _totalPlayTimeController.add(totalPlayTime);
          break;
        case 'onError':
          final error = callArgs['value'] as String;
          this.updatePlayerState(PlayerState.STOPPED);
          _errorController.add(error);
          break;
      }
    } catch (ex) {
      print('Unexpected error: $ex');
    }
  }

  // 播放狀態
  final StreamController<PlayerState> _stateController =
      StreamController<PlayerState>.broadcast();
  Stream<PlayerState> get onPlayerStateChanged => _stateController.stream;

  // Video的時長和當前位置時間變化
  final StreamController<int> _totalPlayTimeController =
      StreamController<int>.broadcast();
  Stream<int> get onTotalTimeChanged => _totalPlayTimeController.stream;

  final StreamController<int> _currentPlayTimeController =
      StreamController<int>.broadcast();
  Stream<int> get onCurrentTimeChanged => _currentPlayTimeController.stream;

  // 發生錯誤
  final StreamController<String> _errorController = StreamController<String>();
  Stream<String> get onError => _errorController.stream;

  // 更新播放狀態
  void updatePlayerState(PlayerState state, {bool stream = true}) {
    _playerState = state;
    if (stream) {
      _stateController.add(state);
    }
  }

  // 這裡需要關閉流
  void dispose() {
    _stateController.close();
    _currentPlayTimeController.close();
    _totalPlayTimeController.close();
    _errorController.close();
  }

  // 格式化時間
  String formatTime(int time) {
    int min = (time ~/ 60);
    int sec = time % 60;
    String minStr = min < 10 ? "0$min" : "$min";
    String secStr = sec < 10 ? "0$sec" : "$sec";
    return "$minStr:$secStr";
  }
}

/// 播放狀態
enum PlayerState {
  STOPPED,
  PLAYING,
  PAUSED,
  COMPLETED,
}

複製程式碼
AppDelegate.swift
import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    
    var playerWrapper: PlayerWrapper?
    
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        
        // 播放器
        let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
        playerWrapper = PlayerWrapper(vc: controller)
        
        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}
複製程式碼
PlayerWrapper.swift
import Foundation
import Flutter
import AVKit
import CoreMedia

class PlayerWrapper: NSObject {
    
    var vc: FlutterViewController
    var channel: FlutterMethodChannel
    var player: AVPlayer?
    var keyVakueObservation: NSKeyValueObservation?
    var notificationObservation: NSObjectProtocol?
    
    init(vc: FlutterViewController) {
        self.vc = vc
        channel = FlutterMethodChannel(name: "netmusic.com/audio_player", binaryMessenger: vc.binaryMessenger)
        super.init()
        channel.setMethodCallHandler(handleFlutterMessage);
    }
    
    // 從Flutter傳過來的方法
    public func handleFlutterMessage(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
        let method = call.method
        let args = call.arguments as? [String: Any]
        
        if method == "play" {
            guard let url = args?["url"] as! String? else {
                NSLog("無播放地址")
                result(0)
                return
            }
            player?.pause()
            guard let audioURL = URL.init(string: url) else {
                NSLog("播放地址錯誤")
                result(0)
                return
            }
            let asset = AVAsset.init(url: audioURL)
            let item = AVPlayerItem.init(asset: asset);
            player = AVPlayer(playerItem: item);
            player?.play();
            
            player?.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: 1), queue: nil, using: { [weak self] (time) in
                self?.channel.invokeMethod("onPosition", arguments: ["value": time.value / Int64(time.timescale)])
            })
            
            keyVakueObservation?.invalidate()
            keyVakueObservation = item.observe(\AVPlayerItem.status) { [weak self] (playerItem, change) in
                let status = playerItem.status
                if status == .readyToPlay {
                    if let time = self?.player?.currentItem?.asset.duration {
                        self?.channel.invokeMethod("onDuration", arguments: ["value": time.value / Int64(time.timescale)])
                    }
                } else if status == .failed {
                    self?.channel.invokeMethod("onError", arguments: ["value": "pley failed"])
                }
            }
            
            notificationObservation = NotificationCenter.default.addObserver(
                forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
                object: item,
                queue: nil
            ) {
                [weak self] (notification) in
                self?.channel.invokeMethod("onComplete", arguments: [])
            }
            
            result(1)
            
        } else if method == "pause" || method == "stop" {
            player?.pause()
            result(1)
        } else if method == "resume" {
            player?.play()
            result(1)
        } else if method == "seek" {
            guard let position = args?["position"] as! Int? else {
                NSLog("無播放時間")
                result(0)
                return
            }
            let seekTime: CMTime = CMTimeMake(value: Int64(position), timescale: 1)
            player?.seek(to: seekTime);
        }
    }
}
複製程式碼

有沒有感覺編寫外掛其實也很簡單,附上所有Flutter程式碼,下篇介紹Android的外掛編寫。

相關文章