Android 蓝牙开发(一) – 传统蓝牙聊天室Android 蓝牙开发(三) – 低功耗蓝牙开发项目工程BluetoothDemo
一、蓝牙概览
以下是蓝牙的介绍,来自维基百科:
蓝牙(英语:Bluetooth),一种无线通讯技术标准,用来让固定与移动设备,在短距离间交换数据,以形成个人局域网(PAN)。其使用短波特高頻(UHF)无线电波,经由2.4至2.485 GHz的ISM频段来进行通信[1]。1994年由电信商爱立信(Ericsson)发展出这个技术[2]。它最初的设计,是希望创建一个RS-232数据线的无线通信替代版本。它能够链接多个设备,克服同步的问题。
简单来讲,就是蓝牙功能支持设备以无线的方式与其他蓝牙设备(手机、音响等)交换数据
本章也重点介绍传统蓝牙,来学习如何使用流的传输方式,实现一个聊天室,效果如下:
客户端 | 服务端 |
1.1 基础知识
为了让支持蓝牙的设备能够通信,它们必须先通过 配对 形成通信通道,可以简单地分为服务端和客户端:
- 服务端:自身可被检测(搜索),并自身设置成可接入请求的状态
- 客户端:支持发现其他可检测蓝牙设备,通过 connect 请求配对设备
当服务端接收配对后,两台设备就完成了绑定过程,并在其期间交换安全密钥,比如两台手机蓝牙连接时,弹出的pin密钥。二者会存储密码,下次连接的时候,就可以自动连接,不用再经历上面的过程。
二、蓝牙开发
下面,一起学习如何使用官方提供的蓝牙实现蓝牙开发。
2.1 权限申请
首先需要先申请权限,如下:
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<!--
If your app targets Android 9 or lower, you can declare
ACCESS_COARSE_LOCATION instead.
-->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
如果想要适配 Android 9(API 28) 或更高版本,则需要申请 ACCESS_FINE_LOCATION 精准定位,如果不适配,则申请 ACCESS_COARSE_LOCATION 即可。
注意,如果是Android 10 ,除了开启蓝牙,还需要开启 gps 功能才行,不然不能搜索和连接其他蓝牙设备
可以做个提示:
//在 Android 10 还需要开启 gps
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val lm: LocationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
if (!lm.isProviderEnabled(LocationManager.GPS_PROVIDER)){
Toast.makeText(this@MainActivity, "请您先开启gps,否则蓝牙不可用", Toast.LENGTH_SHORT).show()
}
}
2.2 设置蓝牙
首先拿到 BluetoothAdapter ,BluetoothAdapter 是所有蓝牙交互的入口点,借助该类,你可以发现其他蓝牙设备,查询已绑定的设备的列表,以及获取 BluetoothDevice 等。开启也很简单,如下:
val bluetooth = BluetoothAdapter.getDefaultAdapter()
如果 为 null,则说明你的设备并没有蓝牙驱动,如果找到了,还可以通过 bluetooth.isEnabled 来判断蓝牙是否开启:
if (bluetooth == null) {
Toast.makeText(this, "您的设备未找到蓝牙驱动!!", Toast.LENGTH_SHORT).show()
finish()
}else {
if (!bluetooth.isEnabled) {
startActivityForResult(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE),1)
}
}
它会弹出一个是否开启蓝牙的提示框,至于是否开启成功,可以在 oActivityResult 中的 result 去判断开启成功与否,比如:
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == 1){
if (resultCode == Activity.RESULT_CANCELED){
Toast.makeText(this, "请您不要拒绝开启蓝牙,否则应用无法运行", Toast.LENGTH_SHORT).show()
finish()
}
}
}
你还可选择监听 ACTION_STATE_CHANGED 广播 ,每当蓝牙状态发生变化时,系统都会广播此 Intent。此广播包含额外字段 EXTRA_STATE 和 EXTRA_PREVIOUS_STATE,二者分别包含新的和旧的蓝牙状态。这些额外字段可能为以下值:STATE_TURNING_ON、STATE_ON、STATE_TURNING_OFF 和 STATE_OFF
2.3 获取蓝牙列表
通过 BluetoothAdapter 可以拿到已经匹配的蓝牙设备,也可以通过 startDiscovery() 方法去查找附近的蓝牙设备,注意只有开启了可被检测的设备才能被查找到。
2.3.1 获取已配对的蓝牙设备
在查找其他设备之前,可以先拿到已配对的设备,通过 getBondedDevices() 方法,该方法会返回一个 BluetoothDevice 的列表,通过它可以获取设备的 mac 地址和名字,如下:
val pairedDevices: Set<BluetoothDevice>? = bluetoothAdapter?.bondedDevices
pairedDevices?.forEach { device ->
val deviceName = device.name
val deviceHardwareAddress = device.address // MAC address
}
BluetoothDevice 表示蓝牙设备,借助该类,可以通过 BluetoothSocket 请求与某个远程设备建立连接,或这查询有关设备的信息,比如连接状态,名称,地址等
2.3.2 发现设备
要发现周围设备,可以使用 startDiscovery() 方法,该方法为异步操作,返回一个 boolean 值,表示该进程是否成功,该进程通常包含约 12 秒的查询扫描,可以通过广播注册 BluetoothDevice.ACTION_FOUND ,然后通过 BluetoothDevice.EXTRA_DEVICE 拿到被发现的设备,如下:
/**
* 查找蓝牙
*/
fun foundDevices(callback: BlueDevFoundListener?) {
blueDevFoundListener = callback;
//先取消搜索
bluetoothAdapter.cancelDiscovery()
//获取已经配对的设备
val bondedDevices = bluetoothAdapter.bondedDevices
bondedDevices?.forEach { device ->
//公布给外面,方便 recyclerview 等设备连接
callback?.let { it(device) }
}
//搜索蓝牙,这个过程大概12s左右
bluetoothAdapter.startDiscovery()
}
然后在 onReceive() 方法中,按到其他设备:
when (intent?.action) {
BluetoothDevice.ACTION_FOUND -> {
val device =
intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
device ?: return
blueDevFoundListener?.let { it(device) }
}
}
在 recyclerview 显示,如下:
2.3.3 启动可检测性
一般手机的蓝牙都是可以被检测到的,如果你发现你的手机蓝牙没被发现,或者你不想一直让别人发现你的手机蓝牙,可以使用 使用 ACTION_REQUEST_DISCOVERABLE 的 Intent, 调用 startActivityForResult(Intent, int) ,这样就可以弃用系统可检测性模式的情趣,从而不用到设置里去配置,默认设备处于可检测模式为 120 s,通过添加 EXTRA_DISCOVERABLE_DURATION Extra 属性,您可以定义不同的持续时间,最高可达 3600 秒(1 小时)。
如果您将 EXTRA_DISCOVERABLE_DURATION Extra 属性的值设置为 0,则设备将始终处于可检测到模式。此配置安全性低,因而非常不建议使用。
以下代码让设备处于可检测模式的时间为 5 分钟 (300s)
val discoverableIntent: Intent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE).apply {
putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300)
}
startActivityForResult(discoverableIntent,1)
设备将在分配的时间内保持可检测模式,可以在onActivityResult 中,检测是否开启成功。
如果你也想知道当前模式发现改变的通知,可以注册广播ACTION_SCAN_MODE_CHANGED,然后接收 BluetoothAdapter 的字段 EXTRA_SCAN_MODE 和 EXTRA_PREVIOUS_SCAN_MODE,二者分别提供新的和旧的扫描模式。每个 Extra 属性可能拥有以下值:
- SCAN_MODE_CONNECTABLE_DISCOVERABLE : 设备处于可检测模式
- SCAN_MODE_CONNECTABLE:设备未处于可检测到模式,但仍能收到连接。
- SCAN_MODE_NONE:设备未处于可检测到模式,且无法收到连接。
2.4 连接设备
如果在两台设备创建连接,需要实现服务端和客户端的机制,其中一台设备开放服务端套接字,而另一台通过 mac 地址发起连接,来连接成功之后,就会建立 RFCOMM 通道,就可以接收到套接字的信息了。
当一台设备连接另一台设备时,Android框架会自动想用户显示请求通知框,RFCOMM请求会一直阻塞,直到用户点击配对成功;或者用户拒绝配对,或者超时时,阻塞才会消失。
如下图:
2.4.1 服务端连接
当两台设备连接时,其中一台充当服务端,它会监听是否有客户端接入,通过以下步骤创建服务端:
- 通过 BluetoothAdapter 的 listenUsingInsecureRfcommWithServiceRecord() 去拿到 BluetoothserverSocket。
它的参数是 (String name, UUID uuid) ,第一个表示服务可识别名称,系统会自动写入到设备上的服务发现协议(SDP)数据库条目,该名称可随意写。uuid 比较关键,你可以连接成 socket 的端口,它是服务的唯一标识,它有128位,所以基本不用担心冲突问题;可以使用 fromString(String) 初始化一个 UUID。注意要和客户端保持一致(端口一致) - 调用 accpet() 监听连接请求,该方法会一致阻塞,当接收或者异常时会返回,只有 uuid 相同时,才会接受连接,返回连接的 BluetoothSocket
- 如果不接受更多的连接,可以使用 close() 方法:如果你不打算连接多个设备,可以使用 close() ,它只会关闭服务端的套接字,但不会关闭 accept() 的 BluetoothSocket 套接字,和 Socket 不同,RFCOMM 一次只允许一个已连接的客户端通信,可以一般都是调用 close() 方法
所以,我们可以这样写:
/**
* 监听是否有设备接入
*/
private inner class AcceptThread(val readListener: HandleSocket.BluetoothListener?,
val writeListener:HandleSocket.BaseBluetoothListener?) : Thread() {
private val serverSocket: BluetoothServerSocket? by lazy {
//非明文匹配,不安全
readListener?.onStart()
bluetooth.listenUsingInsecureRfcommWithServiceRecord(TAG, BlueHelper.BLUE_UUID)
}
override fun run() {
super.run()
var shouldLoop = true
while (shouldLoop) {
var socket: BluetoothSocket? =
try {
//监听是否有接入
serverSocket?.accept()
} catch (e: Exception) {
Log.d(TAG, "zsr blue socket accept fail: ${e.message}")
shouldLoop = false
null
}
socket?.also {
//拿到接入设备的名字
readListener?.onConnected(socket.remoteDevice.name)
//处理接收事件
handleSocket = HandleSocket(socket)
handleSocket.start(readListener,writeListener)
//关闭服务端,只连接一个
serverSocket?.close()
shouldLoop = false;
}
}
}
fun cancel() {
serverSocket?.close()
handleSocket.close()
}
}
可以看到,使用 listenUsingInsecureRfcommWithServiceRecord() 之后,使用 accept() 去等待设备接入,拿到 BluetoothSocket 之后,交给 HandleSocket 去处理读写。这个后面再说
2.4.2 客户端
有了服务端,那么客户端也与 socket 相似,它的步骤如下:
- 使用 BluetoothDevice 的 createRfcommSocketToServiceRecord(UUID) 方法获取 BluetoothSocket。
此方法会初始化 BluetoothSocket 对象,以便客户端连接至 BluetoothDevice。此处传递的 UUID 必须与服务器设备在调用 listenUsingRfcommWithServiceRecord(String, UUID) 开放其 BluetoothServerSocket 时所用的 UUID 相匹配。 - 通过调用 connect() 发起连接。请注意,此方法为阻塞调用。
当客户端调用此方法后,系统会执行 SDP 查找(上面服务端的时候也说了),以找到带有所匹配 UUID 的远程设备。如果查找成功并且远程设备接受连接,则其会共享 RFCOMM 通道,在连接期间就可以通信,并且 connect() 方法将会返回套接字。如果连接失败,或者 connect() 方法超时(约 12 秒后),则此方法将引发 IOException。
代码如下:
/**
* 连接类
*/
inner class ConnectThread(
val device: BluetoothDevice, val readListener: HandleSocket.BluetoothListener?,
val writeListener: HandleSocket.BaseBluetoothListener?
) : Thread() {
var handleSocket: HandleSocket? = null
private val socket: BluetoothSocket? by lazy {
readListener?.onStart()
//监听该 uuid
device.createRfcommSocketToServiceRecord(BlueHelper.BLUE_UUID)
}
override fun run() {
super.run()
//下取消
bluetooth.cancelDiscovery()
try {
socket.run {
//阻塞等待
this?.connect()
//连接成功,拿到服务端设备名
socket?.remoteDevice?.let { readListener?.onConnected(it.name) }
//处理 socket 读写
handleSocket = HandleSocket(this)
handleSocket?.start(readListener, writeListener)
}
} catch (e: Exception) {
readListener?.onFail(e.message.toString())
}
}
fun cancel() {
socket?.close()
handleSocket?.close()
}
}
步骤比较简单,就没好说的了
2.4.3 套接字读写
上面不管是服务端还是客户端都用到 HandleSocket 来处理套接字读写的问题。
首先,可以通过 BluetoothSocket 的 使用 getInputStream() 和 getOutputStream(),分别获取通过套接字处理数据传输的 InputStream 和 OutputStream。
接着使用 使用 read(byte[]) 和 write(byte[]) 读取数据以及将其写入数据流。
这样,我们的读就可以这样写:
/**
* 读取数据
*/
private class ReadThread(val socket: BluetoothSocket?,val bluetoothListener: BaseBluetoothListener?) : Thread() {
//拿到 BluetoothSocket 的输入流
private val inputStream: DataInputStream? = DataInputStream(socket?.inputStream)
private var isDone = false
private val listener :BluetoothListener? = bluetoothListener as BluetoothListener
//todo 目前简单数据,暂时使用这种
private val byteBuffer: ByteArray = ByteArray(1024)
override fun run() {
super.run()
var size :Int? = null
while (!isDone) {
try {
//拿到读的数据和大小
size = inputStream?.read(byteBuffer)
} catch (e: Exception) {
isDone = false
e.message?.let { listener?.onFail(it) }
return
}
if (size != null && size>0) {
//把结果公布出去
listener?.onReceiveData(String(byteBuffer,0,size))
} else {
//如果接收不到数据,则证明已经断开了
listener?.onFail("断开连接")
isDone = false;
}
}
}
fun cancel() {
isDone = false;
socket?.close()
close(inputStream)
}
}
非常简单,拿到inputstream 之后,通过 read() 方法读数据即可,由于是聊天室,所以这里直接转换 String,如果你是要传文件,可以修改成你想要的。
接着是写
/**
* 写数据
*/
class WriteThread(private val socket: BluetoothSocket?,val listener: BaseBluetoothListener?) {
private var isDone = false
//拿到 socket 的 outputstream
private val dataOutput: OutputStream? = socket?.outputStream
//暂时现成的线程池
private val executor = Executors.newSingleThreadExecutor()
fun sendMsg(msg:String){
//通过线程池去发
executor.execute(sendSync(msg))
}
fun cancel(){
isDone = true
executor.shutdownNow()
socket?.close()
close(dataOutput)
}
//用一个 runnable 去复用
private inner class sendSync(val msg:String) :Runnable{
override fun run() {
if (isDone){
return
}
try {
//写数据
dataOutput?.write(msg.toByteArray())
dataOutput?.flush()
}catch (e:Exception){
e.message?.let { listener?.onFail(it) }
}
}
}
}
可以看到,拿到 outputStream 之后,通过 write() 把数据写过去,虽然官网说 write() 一般不会阻塞,但是我们还是放在线程中,这里用线程池去复用一个 runnable,直接发送即可。
这样,我们就学习完了经典蓝牙的聊天室功能,下一章,学习通过 A2DP 的协议,实现手机与蓝牙音响的音频传输。