Flutter的願景是一般的開發者只需要開發Flutter程式碼就能實現跨平臺的應用,官方提供了一些外掛,也有很多可以可以直接拿來使用的第三方外掛。
但是現實是現實,例如當遇到定製化的功能時,編寫外掛是不可避免的。譬如我們有一個自定義協議的藍芽功能,這個功能在Flutter中就不可能直接拿來使用了,需要編寫外掛讓Flutter進行呼叫。本文我們將來看看Flutter外掛是如何實現的。
前言
本文我們用Flutter來仿寫網易雲音樂的播放頁面的功能,其中音樂的播放,音樂的暫停,快進,音樂的時長獲取,音樂播放的進度等功能我們需要用原生程式碼編寫外掛來實現。
提示:本文用音樂播放器的外掛只是為了提供一個編寫Flutter外掛的思路和方法,當需要自己編寫外掛的時候可以方便的來實現。播放音視訊的Flutter外掛已經有一些優秀的三方庫已經實現了。
說明:
- 由於是音訊播放,我製作GIF的時候沒法體現音樂元素,所以音樂只能我自己獨自欣賞了,哈哈~~
- 本文先只介紹iOS的外掛製作,下篇文章我們再來介紹Android的外掛製作。
架構概覽
我們從上面的官方架構圖可以看出,Flutter和Native程式碼是通過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
名字要有意義,其組成遵循"域名"+"/"+"功能",隨意寫就顯得不夠專業。
- 通過
MethodChannel
的invokeMethod
實現播放音樂
/// 播放
Future<int> play() async {
final result = await channel.invokeMethod("play", {'url': audioUrl});
return result ?? 0;
}
複製程式碼
play
就是方法名,{'url': audioUrl}
就是引數invokeMethod
是非同步的,所以返回值需要用Future
包裹。
- 通過
MethodChannel
的invokeMethod
實現暫停音樂
/// 暫停
Future<int> pause() async {
final result = await channel.invokeMethod("pause", {'url': audioUrl});
return result ?? 0;
}
複製程式碼
- 通過
MethodChannel
的invokeMethod
實現繼續播放音樂
/// 繼續播放
Future<int> resume() async {
final result = await channel.invokeMethod("resume", {'url': audioUrl});
return result ?? 0;
}
複製程式碼
- 通過
MethodChannel
的invokeMethod
實現拖動播放位置
/// 拖動播放位置
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)
}
}
複製程式碼
- FlutterAppDelegate的根檢視就是一個FlutterViewController,這個我們在以前的文章中有介紹;
- 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()
}
}
複製程式碼
name
的值必須和Flutter中的對應,否則是沒法通訊的;binaryMessenger
就使用FlutterViewController的FlutterBinaryMessenger,前面提到過。
- 接收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);
}
}
複製程式碼
handleFlutterMessage
這個回撥函式有兩個引數:FlutterMethodCall
接收Flutter傳過來的方法名method
和引數arguments
,FlutterResult
可以返回撥用的結果,例如result(1)
就給Flutter返回了1
這個結果。- 獲取到
FlutterMethodCall
的方法名和引數後就可以進行處理了,我們以play
為例:
- 根據url初始化播放內容,然後開始進行播放;
- 通過
player.addPeriodicTimeObserver
方法檢測播放進度,然後通過FlutterMethodChannel
的invokeMethod
方法傳遞當前的進度給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');
}
}
複製程式碼
- 註冊回撥也是使用
setMethodCallHandler
方法,MethodCall
對應的也包含方法名和引數;- 獲取到對應的資料後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');
}
}
}
複製程式碼
_totalPlayTime
記錄下總播放時長;_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的外掛編寫。