手把手带你写 Flutter 系统音量插件(Android\iOS)

5,814 阅读13分钟

认真读完本文就能掌握编写一个 Flutter 系统音量插件的技能,支持调节系统音量以及监听系统音量变化。 如有不当之处敬请指正。

这篇文章是fijkplayer插件作者 befovylv,原创文章,经作者允许转载。

原文链接

点击打开gif

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.dev/docs/develo…