本
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(MethodCall, result: Result) call: { |
对于 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