Android 低功耗蓝牙开发(扫描、连接、数据交互)Kotlin版

您所在的位置:网站首页 扫描设备 Android 低功耗蓝牙开发(扫描、连接、数据交互)Kotlin版

Android 低功耗蓝牙开发(扫描、连接、数据交互)Kotlin版

2024-01-21 03:48| 来源: 网络整理| 查看: 265

低功耗蓝牙开发(扫描、连接、数据交互)Kotlin版 前言正文一、配置项目二、页面设计三、扫描设备① 绑定视图② 检查Android版本③ 打开蓝牙④ 请求权限⑤ 扫描结果⑥ 设备适配器编写⑦ 数据渲染⑧ 开始和停止扫描 四、连接和数据交互① 绑定视图② 初始化连接③ Ble回调④ 帮助类⑤ UI回调 五、源码

前言

  写这篇文章是因为有读者想看看Kotlin中怎么操作低功耗蓝牙,再加上我也想写一些关于Kotlin的内容,对于低功耗蓝牙的Java版的,我写了两篇,一个是扫描、连接,另一篇就是数据交互,而这篇Kotlin文章我会减少讲解的环节,更多的注重业务逻辑和UI以及Kotlin的语法。

运行效果图 在这里插入图片描述

正文

创建项目 在这里插入图片描述

一、配置项目

  配置项目常规来说两个环节,AndroidManifest.xml和build.gradle。

AndroidManifest.xml

然后是build.gradle,这个地方有两处,一个是项目的build.gradle,增加如下代码:

maven { url "https://jitpack.io"}

在这里插入图片描述 然后是app模块下的build.gradle,增加如下代码:

//使用viewBinding viewBinding { enabled = true } //蓝牙扫描库 implementation 'no.nordicsemi.android.support.v18:scanner:1.5.0' //权限请求 支持Androidx implementation 'com.permissionx.guolindev:permissionx:1.4.0' //让你的适配器一目了然,告别代码冗余 implementation 'com.github.CymChad:BaseRecyclerViewAdapterHelper:3.0.4'

两处代码: 在这里插入图片描述

这里注意一点,viewBinding的开启代码是在android{}闭包中的,不要放错地方了,然后点击Sync或者Sync Now。当程序编译完成之后,运行到自己手机上,先确保项目配置这一步没有问题。

二、页面设计

  首先改一下主题的颜色,列如标题,改成绿色。在colors.xml中增加:

#2b9247 #00cd66

然后修改style中的样式: 在这里插入图片描述

修改activity_main.xml

这里面有两个图标,代码如下: ic_widget.xml

ic_add.xml

下面写扫描到的列表适配器布局文件,在layout下新建一个item_bluetooth.xml,里面的代码如下:

这里也有一个蓝牙图标的ic_bluetooth.xml,如下:

设备扫描页面就差不多了,下面进行这个页面的代码编写。

三、扫描设备

  首先想清楚扫描之前要做什么,扫描之后要做什么。扫描之前要判断Android版本,6.0及以上需要动态请求权限,请求之后要判断蓝牙是否打开,蓝牙打开权限也有了就可以点击扫描蓝牙开始扫描了,扫描时显示加载条表示正在扫描,扫描到设备后添加到列表中,页面上渲染出来。当点击一个设备时连接这个设备,然后就是连接设备后的数据交互了,先写现在的业务逻辑。

① 绑定视图

先进行视图绑定,activity_main.xml 对应的就是ActivityMainBinding。由ViewBinding根据布局生成的

//视图绑定 private lateinit var binding: ActivityMainBinding

然后在onCreate中进行绑定

override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) } ② 检查Android版本

当进入页面是检查版本

/** * Android版本 */ private fun checkAndroidVersion() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) requestPermission() else openBluetooth()

这里的语法就是Kotlin的语法,等价于Java中的如下代码。

private fun checkAndroidVersion() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { requestPermission() } else { openBluetooth() } }

当你用Kotlin时间越久你就越觉得Kotlin设计的好,非常的简洁。当然最主要的是多使用Kotlin,作为弱类型语言,代码的阅读需要有一定的Kotlin基础才可以,高阶的写法可读性很差,但是效率很高代码也很简洁。后面我就直接写Kotlin代码,不熟悉的可以留言提问,事先声明我的Kotlin很菜,所以可读性相对来说高一些。

从上面的方法中可以知道逻辑就是Android6.0以上就请求权限,以下就打开蓝牙。这两个方法现在还都没有的,先写打开蓝牙的方法。

③ 打开蓝牙 //默认蓝牙适配器 private var defaultAdapter = BluetoothAdapter.getDefaultAdapter() /** * 打开蓝牙 */ private fun openBluetooth() = defaultAdapter.let { if (it.isEnabled) showMsg("蓝牙已打开,可以开始扫描设备了") else activityResult.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }

这个方法中主要就是当蓝牙开发未打开的时候,通过Intent去打开系统蓝牙,注意这一行代码:

activityResult.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))

在Android高版本中弃用了startActivityForResult,改用registerForActivityResult。使用此方法需要在onCreate之前进行初始化。

//注册开启蓝牙 注意在onCreate之前注册 private val activityResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) showMsg(if (defaultAdapter.isEnabled) "蓝牙已打开" else "蓝牙未打开") }

这里的showMsg代码如下:

/** * Toast提示 */ private fun showMsg(msg: String = "权限未通过") = Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() ④ 请求权限 /** * 请求权限 */ private fun requestPermission() = PermissionX.init(this).permissions(Manifest.permission.ACCESS_FINE_LOCATION) .request { allGranted, _, _ -> if (allGranted) openBluetooth() else showMsg() }

初始阶段完成了,最终在onCreate方法中调用 在这里插入图片描述

当权限同意之后就打开蓝牙,如果都打开了就可以开始进行扫描蓝牙的操作了,在扫描之后先要确定蓝牙设备需要什么信息。

⑤ 扫描结果

现在前期的准备工作就做好了,那么下面就是点击扫描按钮进行蓝牙设备的扫描了。

//扫描结果回调 private val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { } }

看这段代码相对于Java的区别还是很大的,不过返回的结果值是一样的,然后就是触发回调的地方,这里容我一会儿再写这个开始扫描和停止扫描的方法,因为这两个方法牵扯到的内容比较多,需要控制数据、视图、业务逻辑。因此等先把数据展示出来再去进行这个扫描的开始和结束的操作方法的编写。

⑥ 设备适配器编写

首先我们要定义一个设备类,用来存放扫描到的结果,在Kotlin中有一个数据类,来做这个事情,新建一个BleDevice,代码如下:

data class BleDevice(var device:BluetoothDevice, var rssi:Int, var name:String?)

扫描毫无疑问肯定要展示数据在页面上的。然后就需要一个视图来显示数据,之前创建了item的xml文件,现在我们需要写一个适配器去配合这个item的xm去渲染列表数据。

BaseQuickAdapter的使用,之前我是没有通过ViewBinding去进行布局绑定的,都是通过R.layout.item布局文件进行的,那么换成了ViewBinding要怎么操作呢?BaseQuickAdapter的源码中没有提到ViewBinding,倒是提到了DataBinding,很明显这是两回事,因此我们需要自己扩展一下,让BaseQuickAdapter中可以使用ViewBinding,看下面这一段代码:

class BleDeviceBaseAdapter(layoutResId: Int, data: MutableList?) : BaseQuickAdapter(layoutResId, data) { override fun convert(holder: BaseViewHolder, item: BleDevice) { } }

这是常规的写法,只要传入数据和布局文件的id就可以了,但是现在布局id变成了ViewBinding,因此就需要对这个BaseViewHolder进行一个覆写,这个方式我也是参考了网上博客的内容,

新建一个adapter包,包下新建一个ViewBindingHolder类,里面的代码如下:

class ViewBindingHolder(val vb: VB, view: View) : BaseViewHolder(view)

这里我们自定义了一个ViewBindingHolder,这个类继承了BaseViewHolder,同时构造这个类的时候传入了一个VIewBinding,这说明支持任何ViewBinding,然后就是构造参数vb,view。

在这个ViewBindingHolder类中 新增一个抽象类ViewBindingAdapter,代码如下:

abstract class ViewBindingAdapter(data: MutableList? = null) : BaseQuickAdapter(0, data) { //重写返回自定义 ViewHolder override fun onCreateDefViewHolder(parent: ViewGroup, viewType: Int): ViewBindingHolder { //这里为了使用简洁性,使用反射来实例ViewBinding val viewBindingClass: Class = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class val inflate = viewBindingClass.getDeclaredMethod( "inflate", LayoutInflater::class.java, ViewGroup::class.java, Boolean::class.java ) val mBinding = inflate.invoke(null, LayoutInflater.from(parent.context), parent, false) as VB return ViewBindingHolder(mBinding, mBinding.root) } }

ViewBindingAdapter中传入的参数是VB和T,也就是ViewBinding和Data,然后继承BaseQuickAdapter,传入T和ViewBindingHolder,因为ViewBindingHolder继承了BaseViewHolder,因此是可以这么写的。

然后看下面的这个构造方法。onCreateDefViewHolder,创建默认到的ViewHolder,然后就是根据这个传进来的VB进行一个相应的编译类寻找,因为ViewBinding使用了编译时技术,会在布局完成时构建一个编译类,这个类对应一个xml文件,因此通过这个ViewBinding去反射拿到对应的类,再通过这个类名的中infalte,infalte相信你不会默认,因为在MainActivity中也用到了这个,然后通过infalte去获取mBinding,这个就等价于 在这里插入图片描述 然后mBinding.root对应的就是具体的View,也就是ViewBindingHolder中的View。 在这里插入图片描述 刚才的一系列操作就是通过ViewBinding去获取View,一句话说起来很简单,但是你要实践起来很复杂。

现在这个类就写好了,下面在adapter包下新建一个BleDeviceAdapter类,代码如下:

class BleDeviceAdapter(data: MutableList? = null) : ViewBindingAdapter(data) { @SuppressLint("SetTextI18n") override fun convert(holder: ViewBindingHolder, item: BleDevice) { val binding = holder.vb binding.tvDeviceName.text = item.name binding.tvMacAddress.text = item.device.address binding.tvRssi.text = "${item.rssi} dBm" } }

我相信经过了上面的代码之后,你现在很好理解现在的这种方式,唯一的区别就是从之前的layoutId变成了ViewBinding。ItemBluetoothBinding对应的就是之前的item_bluetooth.xml文件。

⑦ 数据渲染

适配器编写好了,下面就是使用了。先定义一些变量

//低功耗蓝牙适配器 private lateinit var bleAdapter: BleDeviceAdapter //蓝牙列表 private var mList: MutableList = ArrayList() //地址列表 private var addressList: MutableList = ArrayList() //当前是否扫描 private var isScanning = false

然后新增一个页面初始化的方法initView(),代码如下:

private fun initView() { bleAdapter = BleDeviceAdapter(mList).apply { setOnItemClickListener { _, _, position -> stopScan() val device = mList[position].device //跳转页面 } animationEnable = true setAnimationWithDefault(AnimationType.SlideInRight) } binding.rvDevice.apply { layoutManager = LinearLayoutManager(this@MainActivity) adapter = bleAdapter } //扫描蓝牙 binding.fabAdd.setOnClickListener { if (isScanning) stopScan() else scan() } }

在这个方法中我配置了适配器和RecyclerView,最后是浮动按钮的点击事件,用于控制扫描的开始和停止。然后在onCreate中调用这个initView方法。 在这里插入图片描述

然后就是扫描后的数据处理,之前里面可是啥也没有的。增加代码如下图所示: 在这里插入图片描述 当扫描到设备时添加到获取设备地址和设备名称,如果设备名称为null则赋值为Unkown。然后根据地址列表的size去进行数据处理,为空直接添加,不为空则检查地址列表中是否存在之前设备地址,因为一个设备是可以被重复扫描到的,因此这是为了避免重复添加数据。这里的addDeviceList方法,代码如下:

private fun addDeviceList(bleDevice: BleDevice) { mList.add(bleDevice) //无设备UI展示 binding.layNoEquipment.visibility = if (mList.size > 0) View.GONE else View.VISIBLE //刷新列表适配器 bleAdapter.notifyDataSetChanged() }

下面只要扫描设备就可以了,现在写这两个方法,scan和stopScan。

⑧ 开始和停止扫描

开始扫描

/** * 扫描蓝牙 */ private fun scan() { if (!defaultAdapter.isEnabled) { showMsg("蓝牙未打开");return } if (isScanning) { showMsg("正在扫描中...");return } isScanning = true addressList.clear() mList.clear() BluetoothLeScannerCompat.getScanner().startScan(scanCallback) binding.progressBar.visibility = View.VISIBLE binding.fabAdd.text = "扫描中" }

首先判断手机蓝牙是否打开,没打开直接return,然后是判断是否正在扫描中,是直接return,然后设置isScanning = true,下一次点击就会return掉,之后就是清掉之前的设备数据。然后启动扫描,显示加载进度条表示当前正在扫描设备,最后修改浮动按钮的文字。

停止扫描

/** * 停止扫描 */ private fun stopScan() { if (!defaultAdapter.isEnabled) { showMsg("蓝牙未打开");return } if (isScanning) { isScanning = false BluetoothLeScannerCompat.getScanner().stopScan(scanCallback) binding.progressBar.visibility = View.INVISIBLE binding.fabAdd.text = "开始扫描" } }

这个方法就不用解释了,你明白对不对。你现在可以运行一下,不过我打算一气呵成,写完再运行。

四、连接和数据交互

  这里的连接自然还是Gatt连接,同样的新建一个Activity,去哪里进行连接和数据交互操作。新建一个DataExchangeActivity,对应的布局activity_data_exchange.xml。生成了ActivityDataExchangeBinding,然后在onCreate中,进行配置。

① 绑定视图 private lateinit var binding: ActivityDataExchangeBinding

在这里插入图片描述

② 初始化连接

从MainActivity中传递点击的Device过来。回到MainActivity中,添加如下图中所选处代码。 在这里插入图片描述 然后回到DataExchangeActivity中新建一个initView方法,用于页面视图的初始化,同时也要接收传递过来的device。

//Gatt private lateinit var gatt: BluetoothGatt private fun initView() { supportActionBar?.apply { title = "Data Exchange" setDisplayHomeAsUpEnabled(true) } val device = intent.getParcelableExtra("device") //gatt连接 gatt = device!!.connectGatt(this, false, bleCallback) } ③ Ble回调

这里有一个bleCallback,所以你的代码会报红,这很正常,只不过我们现在没有这个类,新建一个callback包,包下我们新建一个BleCallback类来管理回调,代码如下:

class BleCallback : BluetoothGattCallback() { private val TAG = BleCallback::class.java.simpleName private lateinit var uiCallback: UiCallback fun setUiCallback(uiCallback: UiCallback) { this.uiCallback = uiCallback } /** * 连接状态回调 */ override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (status != BluetoothGatt.GATT_SUCCESS) { Log.e(TAG, "onConnectionStateChange: $status") return } uiCallback.state( when (newState) { BluetoothProfile.STATE_CONNECTED -> { //获取MtuSize gatt.requestMtu(512) "连接成功" } BluetoothProfile.STATE_DISCONNECTED -> "断开连接" else -> "onConnectionStateChange: $status" } ) } /** * 获取MtuSize回调 */ override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { uiCallback.state("获取到MtuSize:$mtu") //发现服务 gatt.discoverServices() } /** * 发现服务回调 */ override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { uiCallback.state(if (!BleHelper.enableIndicateNotification(gatt)) { gatt.disconnect() "开启通知属性异常" } else "发现了服务") } /** * 特性改变回调 */ override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { val content = ByteUtils.bytesToHexString(characteristic.value) uiCallback.state("特性改变: 收到内容:$content") } /** * 特性写入回调 */ override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { val command = ByteUtils.bytesToHexString(characteristic.value) uiCallback.state("特性写入: ${if (status == BluetoothGatt.GATT_SUCCESS) "写入成功:" else "写入失败:"}$command") } /** * 描述符写入回调 */ override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { if (BleConstant.DESCRIPTOR_UUID == descriptor.uuid.toString().lowercase(Locale.getDefault())) { uiCallback.state(if (status == BluetoothGatt.GATT_SUCCESS) { gatt.apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) readPhy() readDescriptor(descriptor) readRemoteRssi() } "通知开启成功" } else "通知开启失败") } } /** * 读取远程设备的信号强度回调 */ override fun onReadRemoteRssi(gatt: BluetoothGatt?, rssi: Int, status: Int) = uiCallback.state("onReadRemoteRssi: rssi: $rssi") /** * UI回调 */ interface UiCallback { /** * 当前Ble状态信息 */ fun state(state: String) } } ④ 帮助类

这个类里面会用到一些常量,新建一个utils包,包下新建一个BleConstant类,代码如下:

class BleConstant { companion object BleConstant { /** * 服务 UUID */ const val SERVICE_UUID = "5833ff01-9b8b-5191-6142-22a4536ef123" /** * 描述 UUID */ const val DESCRIPTOR_UUID = "00002902-0000-1000-8000-00805f9b34fb" /** * 特征(特性)写入 UUID */ const val CHARACTERISTIC_WRITE_UUID = "5833ff02-9b8b-5191-6142-22a4536ef123" /** * 特征(特性)表示 UUID */ const val CHARACTERISTIC_INDICATE_UUID = "5833ff03-9b8b-5191-6142-22a4536ef123" } }

同时也把工具类给写一下,utils包下再新建ByteUtils类,代码如下:

object ByteUtils { /** * Convert hex string to byte[] * * @param hexString the hex string * @return byte[] */ fun hexStringToBytes(hexString: String): ByteArray { val hexString = hexString.uppercase(Locale.getDefault()) val length = hexString.length / 2 val hexChars = hexString.toCharArray() val byteArrayResult = ByteArray(length) for (i in 0 until length) { val pos = i * 2 byteArrayResult[i] = (charToByte(hexChars[pos]).toInt().shl(4) or charToByte(hexChars[pos + 1]).toInt()).toByte() } return byteArrayResult } /** * Convert byte[] to string */ fun bytesToHexString(src: ByteArray): String { val stringBuilder = StringBuilder("") for (i in src.indices) { val v = (src[i].toInt() and 0xFF) val hv = Integer.toHexString(v) stringBuilder.append(if (hv.length value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE }) else false /** * 发送指令 * @param gatt gatt * @param command 指令 * @param isResponse 是否响应 */ fun sendCommand(gatt: BluetoothGatt, command: String, isResponse: Boolean): Boolean = gatt.writeCharacteristic(gatt.getService(UUID.fromString(BleConstant.SERVICE_UUID)) .getCharacteristic(UUID.fromString(BleConstant.CHARACTERISTIC_WRITE_UUID)).apply { writeType = if (isResponse) WRITE_TYPE_DEFAULT else WRITE_TYPE_NO_RESPONSE value = ByteUtils.hexStringToBytes(command) }) } ⑤ UI回调

首先在DataExchangeActivity中创建变量:

//Ble回调 private val bleCallback = BleCallback()

现在你的bleCallback 就不会报红了,下面需要实现BleCallback中的UiCallback接口,注意在Kotlin中继承和实现都是 : 。

在这里插入图片描述 然后实现

//状态缓存 private var stringBuffer = StringBuffer() override fun state(state: String) = runOnUiThread { stringBuffer.append(state).append("\n") binding.tvState.text = stringBuffer.toString() }

下面丰富一下initView方法, 在这里插入图片描述 最后再写一个页面返回的方法

//页面返回 override fun onOptionsItemSelected(item: MenuItem): Boolean = if (item.itemId == android.R.id.home) { onBackPressed();true } else false

OK,可以运行了。 在这里插入图片描述

五、源码

GitHub: BleDemo-Kotlin

如果对你有所帮助,欢迎Star 和Fork。我是初学者-Study,山高水长,后会有期~



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3