本
Codelab介绍如何实现Android平台的经典蓝牙功能并封装为一个Flutter Plugin Package被Flutter app或其他Flutter package调用
功能简介
项目需要开发一个 Flutter Packge 以提供以下功能(API):
- 应用内权限申请(经典蓝牙需要定位权限)
- 蓝牙设备列表获取
- 连接经典蓝牙设备
- 通过蓝牙实现文本, 图像或视频的传输

什么是 Flutter Package
Flutter Package 有以下两种类型:
Dart Package: 完全用 Dart 编写的包, 例如 path 包. 其中一些可能包含 Flutter 特定的功能, 这类包完全依赖于 Flutter 框架.
Plugin Package: 一类依赖于运行平台的包, 其中包含用 Dart 代码编写的 API, 并结合了针对 Android (使用 Java 或 Kotlin)和 iOS (使用 ObjC 或 Swift)平台特定的实现. 比如说 battery 包.
为什么需要 Flutter Plugin Package
Flutter 作为一个跨平台的 UI 框架, 本身是不能够直接调用原生的功能的. 如果需要使用原生系统的功能, 就需要对平台特定实现, 然后在 Flutter 的 Dart 层进行兼容.
此处需要使用经典蓝牙的功能, 实现一些业务. 但是 Flutter 现有的蓝牙库并不能满足需求. 因为现有库仅仅支持低功耗蓝牙, 而这里需要用到经典蓝牙实现蓝牙传输文本/图片/视频流
而在这个 Codelab 中因为涉及到蓝牙需要调用平台原生的 API 所以使用 Flutter Plugin Package.
Flutter Plugin Package 是如何工作的
以下是 Flutter Plugin Package 项目的目录:

其中
pubspec.yaml用于添加Plugin可能会用到的依赖或者资源(图片, 字体等)example目录下是一个完整的Flutter APP, 用于测试编写的Plugin另外, 无论在一个
Flutter app项目还是在一个Flutter Plugin项目中都会有三个目录android,ios和lib.lib目录用于存放Dart代码, 而另外两个目录则是用于存放平台特定实现的代码.Flutter会运行根据实际运行平台来运行平台对应的代码, 然后使用 Platform Channels 把代码运行的结果返回给Dart层.
以下是 Flutter 官方给出的一张 Flutter 架构图:

从架构图中可以看到 Flutter 之所以是一个跨平台框架是因为有 Embedder 作为操作系统适配层, Engine 层实现渲染引擎等功能, 而 Framework 层是一个用 Dart 实现的 UI SDK. 对于一个 Flutter Plugin Package 来说, 就是要在 Embedder 层用原生的平台特定实现, 并且在 Dart 层中封装为一个 UI API, 从而实现跨平台. Embedder 层并不能直接和 Framework 直接连接, 还必须经过 Engine 层的 Platform Channels.
使用 Platform Channels 在客户端(UI) 和主机(特定平台)之间传递的过程如下图所示:

创建一个 Flutter Plugin Package
- 打开
Android Studio, 点击New Flutter Project - 选择
Flutter Plugin选项 - 输入项目名字, 描述等信息
Android 平台特定实现
项目下有 android 和 ios 两个目录用于平台特定实现. 首先打开 android/src/main/kotlin/xyz/zhzh/flutter_bt_bluebooth/FlutterBtBluetoothPlugin.kt (flutter_bt_bluebooth 为新建的包名)将里面的代码修改为:
1 | package xyz.zhzh.flutter_bt_bluebooth |
这里 FlutterBtBluetoothPlugin 实现了 MethodCallHandler 接口, 其中 registerWith 方法, 会在创建连接 FlutterBtBluetoothPlugin 的时候执行 (Flutter 会自动执行), 在这个方法里新建了一个 MethodChannel 并打开了名字为 flutter_plugin 的通道并设置监听.
Flutter Plugin 通过覆盖 onMethodCall 方法实现对通道中名为 getPlatformVersion 的消息响应逻辑.
Dart 层调用 Platform Channel 并封装
打开 example/lib/main.dart, 此处使用了 Platform Channel 获取当前运行平台的系统版本:
1 | import 'dart:async'; |
example/lib/main.dart 中封装了一个类 FlutterPlugin, 以此提供一个获取平台版本好的 API, 也就是前面提到的在 Dart 层统一各个平台的差异
调试包
等待创建完成后, 打开项目, 会看到一个自动新建好的 example 项目用作调试 Plugin Package.
添加包依赖
example 默认导入了新建的 Plugin Package, 打开 example/pubspec.yaml 会看到:
1 | dev_dependencies: |
在 APP 中引用包
之前已经在 Dart 层封装好 API 了, example 在 example/lib/main.dart 中可以直接调用这个 API 而不需要考虑平台差异:
1 | // Platform messages are asynchronous, so we initialize in an async method. |
点击运行后, 程序就会自动运行显示出当前运行平台的系统版本:

构建原生 Android 组件
Platform Channel 可以看成 Linux 里面的管道的概念, 双方要通讯是需要花费一定的代价的. 而对于蓝牙传输视频流数据这样的情况, 如果把蓝牙接收到的视频帧再通过 Platform Channel 传输到 Flutter UI, 再由 Flutter 渲染绘制出图像显示出来的话, 会造成很大的开销. 所以这里采用一种技术方案: 把原生 Android 端渲染视频帧, 然后在 Flutter 布局中嵌入原生的 View.
在开发原生组件开发过程中, 热重载功能无法使用. 每次修改后都需要重新编译原生工程才能使之生效
在 Flutter 中添加原生组件的流程如下:
- 实现原生组件
PlatformView, 构建一个原生的view - 实现
PlatformViewFactory用于生成PlatformView - 注册原生组件
- 在
Flutter工程中调用原生View
实现 PlatfromView 接口
要创建可以直接嵌入 Flutter 布局的原生试图. 需要实现 PlatformView 接口. 类在初始化时需要两个参数:
Registrar类型的参数用于之后申请蓝牙权限, 打开Platform Channel等.Int类型的参数用于表示原生试图的id.
创建的原生试图可以基于原生 UI 的一些基础试图, 本 CodeLabs 要显示蓝牙接收到的视频帧, 所以使用 Android 的 ImageView.
1 | // 类名可以自定义 |
实现 PlatformView 接口需要实现两个方法, getView 用于提供 Flutter 要嵌入的 View, dispose 则在试图关闭时执行:
1 | override fun getView(): View { |
创建 PlatformViewFactory
上面创建的类并不能直接被 Flutter 调用, 需要一个继承了 PlatformViewFactory 的类, 创建 View 再返回给 Flutter. 新建一个 BtVideoViewFactory.kt (名字可以自定义) 文件, 在实现 create 方法里面调用上面创建的视图类
1 | import android.content.Context |
注册组件
找到之前的 registerWith 方法, 编写注册组件时的业务逻辑:
1 | companion object { |
在注册的时候, 使用了 NAMESPACE 的字符串作为组件的注册名称, 在 Flutter 调用时需要用到, 具体名称可以自定义.
调用原生 View
打开
plugin package项目的lib/flutter_bt_bluetooth.dart进行编辑(具体文件名依据新建项目时创建的包名).
在 Flutter 中调用原生的 Android 组件需要创建一个 AndroidView 并告诉它组建的注册名称, 创建 AndroidView 的时候, 会给组件分配一个 id, 这个 id 可以通过参数 onPlatformViewCreated 传入方法获得:
1 | AndroidView( |
由于只实现了 Android 平台的组件, 在其他系统上并不可使用, 所以还需要获取 defaultTargetPlatform 来判断运行的平台:
1 | const NAMESPACE = 'plugins.zhzh.xyz/flutter_bt_bluetooth'; |
添加权限申请与监听回调
使用经典蓝牙需要申请定位权限, 应用内申请定位权限后, 在组件初始化时通过 addRequestPermissionsResultListener 添加权限申请结果的监听:
1 | class FlutterBtBluetoothPlugin(r: Registrar, id: Int) : PlatformView, MethodCallHandler { |
LocationRequestPermissionsListener 继承 PluginRegistry.RequestPermissionsResultListener 用来编写收到权限申请结果后的业务逻辑, 此处还设置了一个静态的查询 id 为 REQUEST_COARSE_LOCATION_PERMISSIONS. 申请权限时带上这个 id:
1 | ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), REQUEST_COARSE_LOCATION_PERMISSIONS) |
在监听结果的时候, 就可以根据 id 来判断是否是组件发起的权限申请了:
1 | class FlutterBtBluetoothPlugin(r: Registrar, id: Int) : PlatformView, MethodCallHandler { |
组件添加经典蓝牙
首先需要和开发原生 Android 程序一样在 android/src/main/AndroidManifest.xml 里添加权限:
1 | <manifest xmlns:android="http://schemas.android.com/apk/res/android" |
然后需要添加三个文件, 这三个文件封装了一些调用经典蓝牙的功能, 实现经典蓝牙传输图片:
把这三份代码放在 android/src/main/kotlin/xyz/zhzh 下 (如果包名不一致的话, 请修改代码内的包名)
同时在初始化 view 的时候添加一个 BluetoothUtil 用来之后调用蓝牙:
1 | class FlutterBtBluetoothPlugin(r: Registrar, id: Int) : PlatformView, MethodCallHandler { |
通过 MethodChannel 和 EventChannel 实现原生组件与 Flutter 的通讯
MethodChannel: 用于
Dart层向原生平台通讯EventChannel: 用于原生向平台通讯
设置通道
设置一个 methodChannel 用来响应 Flutter 层的方法调用, 设置一个 eventChannel 来返回蓝牙接受到的数据, 设置一个 stateChannel 来返回蓝牙连接状态, 三个消息通道的名字都以 ${NAMESPACE}/${id}/ 开头, 因为 id 的唯一, 保证不会发生冲突:
1 | /** FlutterBtBluetoothPlugin */ |
然后给三个消息通道设置监听:
1 | init { |
对于 methodChannel 的监听设置的是 this, 而 FlutterBtBluetoothPlugin 已经实现了 MethodCallHandler 接口, 所以直接在 onMethodCall 方法里编写代码:
1 | override fun onMethodCall( call: MethodCall, result: Result) { |
对于 stateChannel, 使用 mBluetoothUtil.setBluetoothConnectionListener 监听蓝牙连接状态, 同时在 stateChannel 被监听 (onListen) 的时候, 记录下通道 EventSink, 并通过 EventSink 把蓝牙连接状态发送到 Dart 层
1 | private fun bluetoothConnectionStreamHandler(): EventChannel.StreamHandler { |
对于 eventChannel, 使用 mBluetoothUtil.setOnDataReceivedListener 监听蓝牙接受到的数据, 同时在 eventChannel 被监听 (onListen) 的时候, 记录下通道 EventSink, 并通过 EventSink 把蓝牙接受到的数据发送到 Dart 层
1 | private fun bluetoothOutputStreamHandler(): EventChannel.StreamHandler { |
android/src/main/kotlin/xyz/zhzh/flutter_bt_bluetooth/FlutterBtBluetoothPlugin.kt 的完整代码见: http://ubibots.zucc.edu.cn/zzh/flutter_bt_bluetooth/blob/master/android/src/main/kotlin/xyz/zhzh/flutter_bt_bluetooth/FlutterBtBluetoothPlugin.kt
在 Dart 层打开消息通道
新建一个 BlueViewController 类用来根据 AndroidView 创建时分配的 id 来打开消息通道
1 | const NAMESPACE = 'plugins.zhzh.xyz/flutter_bt_bluetooth'; |
对于 MethodChannel 类型的消息通道, 需要使用 _channel.invokeMethod 来主动发起请求, 消息通道的数据是异步传输的, 所以在编写请求数据的 API 时, 返回的数据也是异步的:
1 | Future<Map<dynamic, dynamic>> get bondedDevices async => |
对于 EventChannel 类型的消息通道, 是一个 Stream, 使用 receiveBroadcastStream 打开通道后:
1 | Stream<int> get stateStream async* { |
封装三个消息通道所有的 API:
1 | class BlueViewController { |
封装完后, 需要修改一下之前编写的 BlueView, 因为 BlueViewController 需要 AndroidView 创建时生成的 id. 而 Flutter Plugin Package 是为了在编写 Flutter app 时直接调用的, 所以给 BlueView 设置一个参数. 使用 BlueView 的使用可以传入一个函数, 用来自定义对 id 的处理逻辑.
1 | typedef void BlueViewCreatedCallback(BlueViewController controller); |
最终 lib/flutter_bt_bluetooth.dart 的完整代码: http://ubibots.zucc.edu.cn/zzh/flutter_bt_bluetooth/blob/master/lib/flutter_bt_bluetooth.dart

