Android 蓝牙开发(一) – 传统蓝牙聊天室Android 蓝牙开发(三) – 低功耗蓝牙开发项目工程BluetoothDemo

一、蓝牙概览

以下是蓝牙的介绍,来自维基百科:

蓝牙(英语:Bluetooth),一种无线通讯技术标准,用来让固定与移动设备,在短距离间交换数据,以形成个人局域网(PAN)。其使用短波特高頻(UHF)无线电波,经由2.4至2.485 GHz的ISM频段来进行通信[1]。1994年由电信商爱立信(Ericsson)发展出这个技术[2]。它最初的设计,是希望创建一个RS-232数据线的无线通信替代版本。它能够链接多个设备,克服同步的问题。

简单来讲,就是蓝牙功能支持设备以无线的方式与其他蓝牙设备(手机、音响等)交换数据

本章也重点介绍传统蓝牙,来学习如何使用流的传输方式,实现一个聊天室,效果如下:

客户端

服务端

android蓝牙空中广播数据_android蓝牙空中广播数据

android蓝牙空中广播数据_BluetoothDevice_02

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_STATEEXTRA_PREVIOUS_STATE,二者分别包含新的和旧的蓝牙状态。这些额外字段可能为以下值:STATE_TURNING_ONSTATE_ONSTATE_TURNING_OFFSTATE_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 显示,如下:

android蓝牙空中广播数据_客户端_03

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_MODEEXTRA_PREVIOUS_SCAN_MODE,二者分别提供新的和旧的扫描模式。每个 Extra 属性可能拥有以下值:

  • SCAN_MODE_CONNECTABLE_DISCOVERABLE : 设备处于可检测模式
  • SCAN_MODE_CONNECTABLE:设备未处于可检测到模式,但仍能收到连接。
  • SCAN_MODE_NONE:设备未处于可检测到模式,且无法收到连接。

2.4 连接设备

如果在两台设备创建连接,需要实现服务端和客户端的机制,其中一台设备开放服务端套接字,而另一台通过 mac 地址发起连接,来连接成功之后,就会建立 RFCOMM 通道,就可以接收到套接字的信息了。

当一台设备连接另一台设备时,Android框架会自动想用户显示请求通知框,RFCOMM请求会一直阻塞,直到用户点击配对成功;或者用户拒绝配对,或者超时时,阻塞才会消失。

如下图:

android蓝牙空中广播数据_BluetoothDevice_04

2.4.1 服务端连接

当两台设备连接时,其中一台充当服务端,它会监听是否有客户端接入,通过以下步骤创建服务端:

  1. 通过 BluetoothAdapter 的 listenUsingInsecureRfcommWithServiceRecord() 去拿到 BluetoothserverSocket。
    它的参数是 (String name, UUID uuid) ,第一个表示服务可识别名称,系统会自动写入到设备上的服务发现协议(SDP)数据库条目,该名称可随意写。uuid 比较关键,你可以连接成 socket 的端口,它是服务的唯一标识,它有128位,所以基本不用担心冲突问题;可以使用 fromString(String) 初始化一个 UUID。注意要和客户端保持一致(端口一致)
  2. 调用 accpet() 监听连接请求,该方法会一致阻塞,当接收或者异常时会返回,只有 uuid 相同时,才会接受连接,返回连接的 BluetoothSocket
  3. 如果不接受更多的连接,可以使用 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 相似,它的步骤如下:

  1. 使用 BluetoothDevice 的 createRfcommSocketToServiceRecord(UUID) 方法获取 BluetoothSocket。
    此方法会初始化 BluetoothSocket 对象,以便客户端连接至 BluetoothDevice。此处传递的 UUID 必须与服务器设备在调用 listenUsingRfcommWithServiceRecord(String, UUID) 开放其 BluetoothServerSocket 时所用的 UUID 相匹配。
  2. 通过调用 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 的协议,实现手机与蓝牙音响的音频传输。