手把手帶你寫 Flutter 系統音量外掛(Android\iOS)

謝秀嶽lonelyBoy發表於2019-10-21

認真讀完本文就能掌握編寫一個 Flutter 系統音量外掛的技能,支援調節系統音量以及監聽系統音量變化。 如有不當之處敬請指正。

這篇文章是fijkplayer外掛作者 befovylv,原創文章,經作者允許轉載。

原文連結

0、背景

我最近在做一個 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 外掛。

image.png

然後使用 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” 的可點選連結。

image.png

在我使用的這個版本 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 不太熟悉)

image.png

點選 Open for Editing in Android Studio 開啟新的 Android Studio 專案,等 gradle 自動同步完成。 
這是一個完整的 Android App 工程,flutter_volume 外掛作為一個 Android 工程的 modue 存在。外掛的功能實現也主要是修改這個 module 中的程式碼。

image.png

上面的 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…

image.png

翻譯一段官方的釋義

在客戶端,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.yamlREADME.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

手把手帶你寫 Flutter 系統音量外掛(Android\iOS) 手把手帶你寫 Flutter 系統音量外掛(Android\iOS)

參考資料

flutter.dev/docs/develo…

相關文章