認真讀完本文就能掌握編寫一個 Flutter 系統音量外掛的技能,支援調節系統音量以及監聽系統音量變化。 如有不當之處敬請指正。
這篇文章是fijkplayer外掛作者 befovylv,原創文章,經作者允許轉載。
點選開啟gif0、背景
我最近在做一個 Flutter 視訊播放器外掛 fijkplayer,感興趣可以看我的 github。在 0.1.0 版本之後考慮增加調節系統音量功能。google 一番,找到了相關的 Flutter 外掛(Flutter 的生態真的是建立挺快的)。但仔細瞭解外掛的功能之後,感覺有些不滿足我的需求,同時由於我的 fijkplayer 本身就是一個外掛,想盡量避免依賴額外的外掛,所以我幹嘛不自己動手造一個?這可比播放器外掛簡單多了。
本文寫作時播放器外掛 fijkplayer 上已經完成了音量調節和監控的功能,為了文件內容清晰,把相關的程式碼又單獨抽出來作為一個小專案 flutter_volume 。
1、環境介紹
搭建 Flutter 環境這裡不專門講了。直接從 Flutter 外掛的開發環境入手。
本文使用的 Flutter 版本和環境是 [✓] Flutter is fully installed. (Channel stable, v1.9.1+hotfix.2, on Mac OS X 10.14.6 18G95, locale zh-Hans-CN)
建立外掛
新建一個叫做 flutter_volume 的 Flutter 外掛:flutter create --org com.befovy -t plugin -i objc flutter_volume
。flutter create
命令使用引數 -t
選擇模版,可選值為 app
package
plugin
,分別用於建立 Flutter 應用程式,Flutter 包(純 dart 程式碼實現的功能), Flutter 外掛(和主機系統互動)。
我在開始寫 fijkplayer 的時候,預設外掛語言還是 java 和 objc,現在1.9 版本,都已經預設使用 kotlin 和 swift 了。Swift 我還不太熟悉,kotlin 瞭解一些,並且 Android studio 的 java 轉換 kotlin 很強大,我這裡新的小專案 flutter_volume 就也使用 kotlin 和 objc 了。如果要修改建立 Flutter 外掛使用的程式語言,可以使用引數 -i
和 -a
。
例如 flutter create -t plugin -a java -i swift flutter_volume
外掛目錄結構
先在 Android Studio 中安裝 Flutter 外掛和 Dart 外掛。
然後使用 Android Studio 開啟剛才建立的 plugin 專案目錄 flutter_volume。注意是使用 Android Studio 中的 “Open an existing Android Studio project" 選單。
使用 Android Studio 開啟 Flutter 專案後,其結構如下。
Flutter plugin 的功能實現基本上就是 dart 程式碼和 android 本地 kotlin/java 以及 iOS 本地 swift/objc 程式碼互相呼叫。
實現這些功能的程式碼就在下圖中 libs 目錄中的 dart 原始檔,android/src 目錄中的 java/kotlin 原始檔,以及 ios/Classes 目錄中的 objc/swift 原始檔。
在這個 Android Studio 工程中隨便開啟一個 android 目錄內的檔案,都會編輯器右上角出現 “Open for Editing in Android Studio” 的可點選連結,開啟 ios 資料夾的任意檔案,都會出現類似 “Open for Editing in XCode” 的可點選連結。
在我使用的這個版本 flutter 中,新專案直接使用 Xcode 開啟會存在一些問題。解決辦法是先在 example/ios 資料夾,執行 pod install
。之後再點選 “Open for Editing in XCode” 開啟 Xcode 專案,或者使用 Xcode 開啟 example/ios/Runner.xcworkspace 工程。
劃重點
先在命令後 example/ios 資料夾,執行 pod install
,然後再開啟 Xcode 專案。
開啟 xcode 後看到,外掛的 objc/swift 程式碼被 pod 使用檔案連結套了很長的路徑,寫iOS外掛主要就是在這個資料夾的程式碼中實現功能。(截圖還是 swift 的外掛專案,後來為了進度改成了 objc,畢竟對 swift 不太熟悉)
點選 Open for Editing in Android Studio 開啟新的 Android Studio 專案,等 gradle 自動同步完成。
這是一個完整的 Android App 工程,flutter_volume 外掛作為一個 Android 工程的 modue 存在。外掛的功能實現也主要是修改這個 module 中的程式碼。
上面的 Xcode 工程以及這個 Android Studio 工程,都是可以執行的 App 工程,這個 Flutter 工具已經幫我們打理好了,建立 Flutter plugin 的時候就預設帶有 example。
上面大圖 1 中 example 資料夾中的目錄結構就和一個普通的 Flutter App 目錄結構一樣,只是這裡 Flutter App 使用相對路徑依賴的外層資料夾的 flutter_volume 外掛。
大圖 2 和 大圖 3 開啟的其實就是 example 檔案中 android 和 iOS 專案。
這種 Flutter 工具自動生成的外掛目錄結構確實對程式設計師非常友好,寫了外掛立馬就能在 demo 中看到效果。
2、Flutter Native 通訊方式
Flutter 應用可以在 iOS 和 Android 平臺執行,肯定要和原生系統進行各種各樣的互動。互動的部分主要是在 flutter engine 中,以及大量的 flutter 外掛中。
MethodChannel
Flutter 框架提供了這樣的互動方式。訊息通過 Method Channel 在客戶端(UI)和主機(platform)之間傳遞。
官方文件這裡使用的是 platform channels,翻譯的時候我使用了更具體直接的表述 Method Channel
見下圖(圖片來源 flutter.dev/docs/develo…)
翻譯一段官方的釋義
在客戶端,MethodChannel 可以傳送與方法呼叫相對應的訊息。 在平臺方面,Android 上的 MethodChannel和 iOS 上的 FlutterMethodChannel 允許接收方法呼叫併傳送回結果。 這些類使您可以使用很少的“樣板程式碼”來開發平臺外掛。 注意:如果需要,方法呼叫也可以反向傳送,平臺充當Dart中實現的方法的客戶端。
上圖形象表達了 Flutter 傳送訊息到 native 端的過程。
同時,我們需要注意,這個過程可以反過來從 native 端主動傳送訊息到 Flutter 端。即在 native 端建立 MethodChannel 並進行方法呼叫,Flutter 端進行方法處理並且傳送會方法呼叫結果。實際中更常用的是對於這個模式的更高一層封裝 EventChannel。Native 端進行 event 傳送,Flutter 端進行 event 響應。
MethodChannel 和 EventChannel 都會在後面實戰環節使用到,一看即會。
在 Flutter 客戶端和 native 平臺方面傳遞資料都是需要經過編碼再解碼。
編碼的方式預設的是用StandardMethodCodec
,此外還有 JSONMethodCodec
。StandardMethodCodec
編解碼效率更高。
編碼資料型別
MethodCodec 支援的資料型別以及在 dart 、iOS 和 Android 中的對應關係如下表。
Dart | Android | iOS |
---|---|---|
null | null | nil (NSNull when nested) |
bool | java.lang.Boolean | NSNumber numberWithBool: |
int | java.lang.Integer | NSNumber numberWithInt: |
int, if 32 bits not enough | java.lang.Long | NSNumber numberWithLong: |
double | java.lang.Double | NSNumber numberWithDouble: |
String | java.lang.String | NSString |
Uint8List | byte[] | FlutterStandardTypedData typedDataWithBytes: |
Int32List | int[] | FlutterStandardTypedData typedDataWithInt32: |
Int64List | long[] | FlutterStandardTypedData typedDataWithInt64: |
Float64List | double[] | FlutterStandardTypedData typedDataWithFloat64: |
List | java.util.ArrayList | NSArray |
Map | java.util.HashMap | NSDictionary |
# 3、Volume 介面 前面提到是要在一個視訊播放器外掛中調整系統的音量。經過梳理,先整理出初步需要的介面。主要有增大音量、減小音量、靜音、獲取音量、設定音量。同時還有啟用音量變化監聽、設定音量變化監聽、關閉音量變化監聽。為了使用方便,還增加了一個 VolumeWatcher 的 Widget,在其中成對使用了新增音量變化監聽,取消音量變化監聽介面。
部分程式碼如下,完整程式碼請 點選連結檢視 。
class VolumeVal {
final double vol;
final int type;
}
typedef VolumeCallback = void Function(VolumeVal value);
class FlutterVolume {
static const double _step = 1.0 / 16.0;
static const MethodChannel _channel =
const MethodChannel('com.befovy.flutter_volume');
static _VolumeValueNotifier _notifier =
_VolumeValueNotifier(VolumeVal(vol: 0, type: 0));
static StreamSubscription _eventSubs;
void enableWatcher() {
if (_eventSubs == null) {
_eventSubs = EventChannel('com.befovy.flutter_volume/event')
.receiveBroadcastStream()
.listen(_eventListener, onError: _errorListener);
_channel.invokeMethod("enable_watch");
}
}
void disableWatcher() {
_channel.invokeMethod("disable_watch");
_eventSubs?.cancel();
_eventSubs = null;
}
static void _eventListener(dynamic event) {
final Map<dynamic, dynamic> map = event;
switch (map['event']) {
case 'vol':
double vol = map['v'];
int type = map['t'];
_notifier.value = VolumeVal(vol: vol, type: type);
break;
default:
break;
}
}
static Future<double> up({double step = _step, int type = STREAM_MUSIC}) {
return _channel.invokeMethod("up", <String, dynamic>{
'step': step,
'type': type,
});
}
static void addVolListener(VoidCallback listener) {
_notifier.addListener(listener);
}
}
class VolumeWatcher extends StatefulWidget {
final VolumeCallback watcher;
final Widget child;
VolumeWatcher({
@required this.watcher,
@required this.child,
});
@override
_VolumeWatcherState createState() => _VolumeWatcherState();
}
複製程式碼
這裡既使用了 MethodChannel, 也使用了 EventChannel。Flutter 使用 MethodChannel 傳送方法呼叫請求到 native 側,並獲取方法的呼叫結果。為了避免 UI 卡頓,方法呼叫都使用非同步模式。EventChannel 則是在 Flutter 端處理 native 傳送的事件通知。
在 Flutter 中,所有 Channel 的 name 必須是不重複的,否則訊息傳送會出錯。
MethodChannel
的使用很簡單,使用 name 引數構造一個MethodChannel
,並使用invokeMethod
進行訊息和引數的傳送,並返回非同步的結果。EventChannel
使用稍微複雜一些,但都是一些樣板程式碼。構造EventChannel
並監聽事件廣播,註冊事件處理函式和錯誤處理函式。使用完成後再取消廣播訂閱。
介面設計中,我加上了不同音訊型別的可選引數 type
,但在初期的實現中,只會實現媒體聲音型別的相關功能。
這個可選引數保證後期的功能實現,介面不發生變化。
完整的程式碼變更可以看github 上這個提交。
github.com/befovy/flut…
4、iOS 功能實現
FlutterPluginRegistrar
FlutterPluginRegistrar 是 flutter 外掛在 iOS 環境中的上下文,提供外掛上下文資訊,以及 App 回撥事件資訊。
FlutterPluginRegistrar 的例項物件需要儲存在 Plugin class 的成員變數中,方便後續使用。
將 FlutterVolumePlugin 的無參 init 函式調整為 initWithRegistrar 。
@implementation FlutterVolumePlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar {
FlutterMethodChannel *channel =
[FlutterMethodChannel methodChannelWithName:@"com.befovy.flutter_volume"
binaryMessenger:[registrar messenger]];
FlutterVolumePlugin *instance =
[[FlutterVolumePlugin alloc] initWithRegistrar:registrar];
[registrar addMethodCallDelegate:instance channel:channel];
}
- (instancetype)initWithRegistrar:
(NSObject<FlutterPluginRegistrar> *)registrar {
self = [super init];
if (self) {
_registrar = registrar;
}
return self;
}
@end
複製程式碼
iOS 監聽音量變化
ios 系統通知中心有關於音量變化的廣播,監聽音量變化只需要在通知中心註冊通知即可。
根據介面設計,監聽系統音量變化,有兩個介面呼叫控制功能開啟或者關閉。
音量監聽的主要程式碼實現如下:
@implementation FlutterVolumePlugin
- (void)enableWatch {
if (_eventListening == NO) {
_eventListening = YES;
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(volumeChange:)
name:@"AVSystemController_SystemVolumeDidChangeNotification"
object:nil];
_eventChannel = [FlutterEventChannel
eventChannelWithName:@"com.befovy.flutter_volume/event"
binaryMessenger:[_registrar messenger]];
[_eventChannel setStreamHandler:self];
}
}
- (void)disableWatch {
if (_eventListening == YES) {
_eventListening = NO;
[[NSNotificationCenter defaultCenter]
removeObserver:self
name:@"AVSystemController_SystemVolumeDidChangeNotification"
object:nil];
[_eventChannel setStreamHandler:nil];
_eventChannel = nil;
}
}
- (void)volumeChange:(NSNotification *)notification {
NSString *style = [notification.userInfo
objectForKey:@"AVSystemController_AudioCategoryNotificationParameter"];
CGFloat value = [[notification.userInfo
objectForKey:@"AVSystemController_AudioVolumeNotificationParameter"]
doubleValue];
if ([style isEqualToString:@"Audio/Video"]) {
[self sendVolumeChange:value];
}
}
- (void)sendVolumeChange:(float)value {
if (_eventListening) {
NSLog(@"valume val %f\n", value);
[_eventSink success:@{@"event" : @"volume", @"vol" : @(value)}];
}
}
@end
複製程式碼
enableWatch 中在通知中心註冊關於音量變化的處理函式。然後構造 FlutterEventChannel 並且設定 handler。
disableWatch 中移除在通知中心註冊的回撥,然後刪除 EventChannel 的 handler,並刪除 eventChannel 物件。
需要注意的是,dart中 EventChannel('xxx').receiveBroadcastStream()
的呼叫一定要在 native 端執行完成 FlutterEventChannel setStreamHandler
方法之後,否則會出現 onListen
方法找不到的錯誤。
系統音量修改
iOS 中沒有公開的修改系統音量介面,但是還有其他途徑實現音量修改。目前使用最廣泛的就是在 UI 中插入一個不可見的 MPVolumeView,然後模擬 UI 操作調整其中的 MPVolumeSlider。
@implementation FlutterVolumePlugin
- (void)initVolumeView {
if (_volumeView == nil) {
_volumeView =
[[MPVolumeView alloc] initWithFrame:CGRectMake(-100, -100, 10, 10)];
_volumeView.hidden = YES;
}
if (_volumeViewSlider == nil) {
for (UIView *view in [_volumeView subviews]) {
if ([view.class.description isEqualToString:@"MPVolumeSlider"]) {
_volumeViewSlider = (UISlider *)view;
break;
}
}
}
if (!_volumeInWindow) {
UIWindow *window = UIApplication.sharedApplication.keyWindow;
if (window != nil) {
[window addSubview:_volumeView];
_volumeInWindow = YES;
}
}
}
- (float)getVolume {
[self initVolumeView];
if (_volumeViewSlider == nil) {
AVAudioSession *audioSession = [AVAudioSession sharedInstance];
CGFloat currentVol = audioSession.outputVolume;
return currentVol;
} else {
return _volumeViewSlider.value;
}
}
- (float)setVolume:(float)vol {
[self initVolumeView];
if (vol > 1.0) {
vol = 1.0;
} else if (vol < 0) {
vol = 0.0;
}
[_volumeViewSlider setValue:vol animated:FALSE];
vol = _volumeViewSlider.value;
return vol;
}
@end
複製程式碼
完整 iOS 外掛程式碼 點我檢視
5、Android 功能實現
Android Flutter 外掛開發離不開 flutter engine 中的介面 Registrar。通過 Registrar 的方法可以獲取 activity、 context 等 Android 開發中重要物件。
Registrar
public interface Registrar {
Activity activity();
Context context();
Context activeContext();
....
}
複製程式碼
class FlutterVolumePlugin(registrar: Registrar): MethodCallHandler {
companion object {
@JvmStatic
fun registerWith(registrar: Registrar) {
val channel = MethodChannel(registrar.messenger(), "flutter_volume")
channel.setMethodCallHandler(FlutterVolumePlugin(registrar))
}
}
private val mRegistrar: Registrar = registrar
}
複製程式碼
對自動生成的 Plugin class 進行修改,增加 mRegistrar 成員變數(見上面程式碼片段),在成員函式 onMethodCall
中處理 method call 的時候就可以獲取 activity、context 等重要變數。
比如 Android 系統中音量修改使用的 AudioManager 。
class FlutterVolumePlugin(registrar: Registrar): MethodCallHandler {
private fun audioManager(): AudioManager {
val activity = mRegistrar.activity()
return activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
}
複製程式碼
Android 中音量調節功能的實現主要就是 AudioManager 的 API 呼叫,以及對 flutter onMethodCall 方法的處理。詳細的內容請點選檢視原始碼。
監聽音量的變化
Android 系統中使用廣播通知 BroadcastReceiver 獲取音量變化。
根據介面設計,監聽系統音量變化,有兩個介面呼叫控制功能開啟或者關閉。
在 enableWatch
方法中,先修改標記變數 mWatching
, 然後建立 EventChannel
並且呼叫 setStreamHandler
方法。最後,註冊廣播接收器,接受系統音量變化的通知。
需要注意的是,dart中 EventChannel('xxx').receiveBroadcastStream()
的呼叫一定要在 native 端執行完成 setStreamHandler
方法之後,否則會出現 onListen
方法找不到的錯誤。
class FlutterVolumePlugin(registrar: Registrar) : MethodCallHandler {
private fun enableWatch() {
if (!mWatching) {
mWatching = true
mEventChannel = EventChannel(mRegistrar.messenger(), "com.befovy.flutter_volume/event")
mEventChannel!!.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(o: Any?, eventSink: EventChannel.EventSink) {
mEventSink.setDelegate(eventSink)
}
override fun onCancel(o: Any?) {
mEventSink.setDelegate(null)
}
})
mVolumeReceiver = VolumeReceiver(this)
val filter = IntentFilter()
filter.addAction(VOLUME_CHANGED_ACTION)
mRegistrar.activeContext().registerReceiver(mVolumeReceiver, filter)
}
}
private fun disableWatch() {
if (mWatching) {
mWatching = false
mEventChannel!!.setStreamHandler(null)
mEventChannel = null
mRegistrar.activeContext().unregisterReceiver(mVolumeReceiver)
mVolumeReceiver = null
}
}
}
複製程式碼
在獲取音量變化通知 BroadcastReceiver 的 onReceive 方法中, 使用 EventChannel 傳送到事件內容到 flutter 側。
private class VolumeReceiver(plugin: FlutterVolumePlugin) : BroadcastReceiver() {
private var mPlugin: WeakReference<FlutterVolumePlugin> = WeakReference(plugin)
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == "android.media.VOLUME_CHANGED_ACTION") {
val plugin = mPlugin.get()
if (plugin != null) {
val volume = plugin.getVolume()
val event: MutableMap<String, Any> = mutableMapOf()
event["event"] = "vol"
event["v"] = volume
event["t"] = AudioManager.STREAM_MUSIC
plugin.sink(event)
}
}
}
}
class FlutterVolumePlugin(registrar: Registrar) : MethodCallHandler {
fun sink(event: Any) {
mEventSink.success(event)
}
}
複製程式碼
詳細的內容請點選檢視原始碼。
音量區間對映
在 Android 系統中,音量最大值有可能不一樣,範圍不是 [0, 1]。此外掛獲取音量最大值後,將音量又線性對映到 [0, 1] 的範圍中。另一點需要注意,android 音量調節不是無級調節,有一個調節的最小單元,將這個最小單元對映到 [0, 1] 範圍中的一個 delta 值,並保證調節音量 step 值大於等於這個最小單元 delta 值,否則音量調節無效。
在外掛的 API 實現中,如果呼叫 up
或 down
介面, step
引數值小於 delta
,則會被修改為 delta
的值,保證 up
或 down
介面的呼叫都是有效的。
6、外掛 Demo
flutter 外掛建立的預設目錄中都包含一個 example 資料夾。裡面是一個完整的 flutter app 工程目錄,使用相對路徑的方式引用了外層資料夾中的 flutter 外掛。
dev_dependencies:
flutter_volume:
path: ../
複製程式碼
在 lib/main.dart 中引入外掛
import 'package:flutter_volume/flutter_volume.dart';
然後簡單寫幾個按鈕,在 onPressed 中呼叫 flutter_volume.dart 中的API 就可以完整外掛的示例 App。
詳細內容請看完整的原始碼 example/lib/main.dart
7、釋出外掛
完成了外掛或者 dart 包的開發測試之後,可以將其釋出到 Pub 上,這樣其他開發人員就可以快捷方便地使用它。
Flutter 的依賴管理 pubspec 支援通過本地路徑和 Git 匯入依賴,但使用 pub 可以更方便進行外掛版本管理。
volume flutter_volume 這幾個名字都已經被佔坑了,我就暫時不釋出到 pub 了
釋出外掛到 pub ,需要登入 google 賬號,請預先準備梯子。
在釋出之前,先檢查 pubspec.yaml
、README.md
以及 CHANGELOG.md
、 LICENSE
檔案,以確保其內容的完整性和正確性。pubspec.yaml
裡除了外掛的依賴,還包含一些外掛以及作者的元資訊,需要把這些補上:
name: flutter_volume
description: A Plugin for Volume Control and Monitoring, support iOS and Android
version: 0.0.1
author: befovy
homepage: blog.befovy.com
複製程式碼
然後, 執行 dry-run 命令以檢視外掛是否還有別的問題:
flutter packages pub publish --dry-run
複製程式碼
如果命令輸出 Package has 0 warnings
,則表示一切正常。
最後,執行釋出命令 flutter packages pub publish
如果是第一次釋出,會提示驗證 Google 賬號。
Looks great! Are you ready to upload your package (y/n)? y
Pub needs your authorization to upload packages on your behalf.
In a web browser, go to https://accounts.google.com/o/oauth2/auth?access_type=offline*****.....(省略一千字)
Then click "Allow access".
Waiting for your authorization...
Successfully authorized.
Uploading...
Successful uploaded package.
複製程式碼
成功授權之後便可以繼續上傳,上傳成功後,會提示 Successful uploaded package
。
釋出後,可以在 pub.dartlang.org/packages/${… 檢視釋出情況。
都看到這裡了,不妨關注一下我的小店,店中當季新鮮石榴還可以使用專屬優惠券,進入微店可以加我微信哦,加微信請註明 flutter_volume