Android 低功耗蓝牙开发 (扫描过滤、自定义服务与特性)Kotlin版

2021-10-18 10:50:03 浏览数 (1)

扫描过滤、自定义服务与特性

  • 前言
  • 正文
    • 一、扫描过滤
      • ① 页面设计
      • ② 添加菜单
      • ③ 过滤设置弹窗
      • ④ 过滤设置缓存
      • ⑤ 扫描设备过滤
    • 二、自定义服务与特性
      • ① 弹窗设置
      • ② 显示弹窗
      • ③ 添加菜单
      • ④ UUID检查
    • 三、源码

前言

  之前写过Kotlin版的低功耗蓝牙的扫描连接交互的文章,后面我在实践过程了也发现了一些问题,那就是当我从一个设备换到另一个设备时,需要去改动代码才行,这无疑给调试带了很大的困扰,因此我想对这个App做进一步的优化,有可能会做成一个低功耗蓝牙的通用调试App,最好能满足所有的调试需求,当然这是我的一个想法,下面开始吧。

正文

  毫无疑问,这也是一个续作,想要结果的直接滑动到底部看源码即可,想了解具体过程的,可以先看看上一篇:Android 低功耗蓝牙开发(扫描、连接、数据交互)Kotlin版,好了下面就不说废话了,开始吧!

一、扫描过滤

  首先看看扫描过滤的实现,先说说这个功能的使用场景,当附近蓝牙设备很多时,快速找到想要连接调试的设备,这是这个功能的初衷,同时在扫描蓝牙时可以过滤掉一些没有名字的设备,信号强度低的设备,或指定设备地址的设备,这才调试过程中都是比较常见的需求,因此我们一步一步的来实现这些功能需求。

① 页面设计

  在添加功能的同时要考虑页面的合理和UI美化,不能说怎么简单怎么来,对自己要有要求,首先看看之前的扫描页面

首先页面上很空旷,那么我们增加功能可以使用隐藏的方式,例如加一个菜单,右上角加三个点,同时我们把底部浮动按钮的文字改一下,改成开始扫描,这就补贴图说明了,直接在activity_main.xml中改动就可以了。

② 添加菜单

下面在页面上添加一个菜单用来作为页面其他功能的入口。首先在res下新建一个menu文件夹,然后在menu文件夹下新建一个main_menu.xml文件。

里面的代码如下:

代码语言:javascript复制
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">
    <item
        android:id="@ id/item_scan_filter"
        android:title="扫描过滤"
        app:showAsAction="never"
        tools:ignore="HardcodedText" />
menu>

然后回到MainActivity中,重写两个方法,如下:

代码语言:javascript复制
	/**
     * 创建菜单
     */
    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        menuInflater.inflate(R.menu.main_menu,menu)
        return true
    }

    /**
     * 菜单点击
     */
    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            R.id.item_scan_filter -> showMsg("扫描过滤")
            else -> showMsg("Do nothing...")
        }
        return true
    }

然后运行一下:

这都是很简单的操作了,是吧,下面我们来写一个用于设置过滤内容的弹窗。

③ 过滤设置弹窗

  说到弹窗最简单的方式就是使用Android自带的弹窗,我比较喜欢用底部弹窗BottomSheetDialog,基本满足需求就不需要自己去自定义了。

首先在colors.xml中增加一个颜色,是分割线的颜色

代码语言:javascript复制
<color name="line">#EEEcolor>

然后通过drawable绘制一个顶部左右圆角的背景,在drawable下新建一个shape_white_top_radius_24.xml文件,代码如下:

代码语言:javascript复制
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/white" />
    <corners
        android:topLeftRadius="24dp"
        android:topRightRadius="24dp" />
shape>

下面在layout下新建一个dialog_scan_filter.xml文件,代码如下:

代码语言:javascript复制
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@drawable/shape_white_top_radius_24"
    android:orientation="vertical">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="60dp"
        android:gravity="center"
        android:text="扫描过滤"
        android:textColor="@color/black"
        android:textSize="16sp" />

    <View
        android:layout_width="match_parent"
        android:layout_height="0.5dp"
        android:background="@color/line" />

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="60dp"
        android:orientation="horizontal"
        android:padding="16dp">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true"
            android:text="过滤设备名为空的设备"
            android:textColor="@color/black" />

        <androidx.appcompat.widget.SwitchCompat
            android:id="@ id/switch_device_name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentEnd="true"
            android:theme="@style/SwitchStyle"
            tools:ignore="UseSwitchCompatOrMaterialXml" />
    RelativeLayout>

    <View
        android:layout_width="match_parent"
        android:layout_height="0.5dp"
        android:background="@color/line" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="60dp"
        android:gravity="center_vertical"
        android:orientation="horizontal"
        android:padding="16dp">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="RSSI:"
            android:textColor="@color/black" />

        <androidx.appcompat.widget.AppCompatSeekBar
            android:id="@ id/sb_rssi"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="12dp"
            android:layout_marginEnd="12dp"
            android:layout_weight="1"
            android:max="100"
            android:min="40"
            android:progress="100"
            android:theme="@style/SeekBarStyle" />

        <TextView
            android:id="@ id/tv_rssi"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="-100 dBm"
            android:textColor="@color/black" />
    LinearLayout>

    <View
        android:layout_width="match_parent"
        android:layout_height="0.5dp"
        android:background="@color/line" />

    <TextView
        android:id="@ id/tv_close"
        android:layout_width="match_parent"
        android:layout_height="60dp"
        android:foreground="?attr/selectableItemBackground"
        android:gravity="center"
        android:text="关闭"
        android:textColor="@color/black" />

LinearLayout>

在styles.xml中增加如下样式:

代码语言:javascript复制
	<style name="BottomSheetDialogStyle" parent="Theme.Design.BottomSheetDialog">
        "android:windowFrame">@null
        "android:windowIsFloating">true
        "android:windowIsTranslucent">true
        "android:background">@android:color/transparent
        "android:backgroundDimEnabled">true
    style>

    <style name="SwitchStyle" parent="Theme.AppCompat.Light">
        
        "colorControlActivated">@color/green_light
        
        "colorSwitchThumbNormal">@color/gray_light
        
        "android:colorForeground">@color/gray_dark
    style>

    <style name="SeekBarStyle" parent="Widget.AppCompat.SeekBar">
        "colorAccent">@color/green_light
    style>

布局有了,下面进入MainActivity中写代码。增加一个方法:

代码语言:javascript复制
	/**
     * 显示扫描过滤弹窗
     */
    @SuppressLint("InflateParams")
    private fun showScanFilterDialog() = BottomSheetDialog(this, R.style.BottomSheetDialogStyle).apply {
            setContentView(DialogScanFilterBinding.bind(View.inflate(context, R.layout.dialog_scan_filter, null)).apply {
                    switchDeviceName.setOnCheckedChangeListener { buttonView, isChecked -> }
                    sbRssi.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
                        override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {}
                        override fun onStartTrackingTouch(seekBar: SeekBar) {}
                        override fun onStopTrackingTouch(seekBar: SeekBar) {}
                    })
                    tvClose.setOnClickListener { dismiss() }
                }.root)
            window?.findViewById<View>(R.id.design_bottom_sheet)?.setBackgroundColor(Color.TRANSPARENT)
        }.show()

这里面有一个开关一个滑动条,开关用于确定过滤设备名称为null的设备。首先来看这个开关,这里应该要保存开关的状态,每次每一次设置也是比较麻烦的,因此可以用一个本地缓存记录下来,通过也可以记录滑动条的位置,保存信号值强度。

④ 过滤设置缓存

  写缓存会用到什么?上下文参数,那么就可以弄一个全局的上下文,怎么弄?自定义Application,在com.llw.blekotlin包下新建一个BleApplication类,代码如下:

代码语言:javascript复制
open class BleApplication : Application() {

    companion object {
        @SuppressLint("StaticFieldLeak")
        lateinit var context: Context
    }

    override fun onCreate() {
        super.onCreate()
        context = applicationContext
    }
}

很简单的代码,目前来说只有一个上下文,然后我们在AndroidManifest.xml中的application标签中去配置一下。

然后在utils包下新增一个扩展类,用于根据不同的数据类型进行缓存处理。实际上就是一个Kotlin文件,代码如下:

代码语言:javascript复制
const val NAME = "config"

@SuppressLint("StaticFieldLeak")
val context = BleApplication.context

val sp: SharedPreferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)

fun Boolean.putBoolean(key: String) = sp.edit().putBoolean(key, this).apply()

fun getBoolean(key: String, result: Boolean = false): Boolean = sp.getBoolean(key, result)

fun String?.putString(key: String) = sp.edit().putString(key, this).apply()

fun getString(key: String, result: String? = null): String? = sp.getString(key, result)

fun Int.putInt(key: String) = sp.edit().putInt(key, this).apply()

fun getInt(key: String, result: Int = 0): Int = sp.getInt(key, result)

fun Long.putLong(key: String) = sp.edit().putLong(key, this).apply()

fun getLong(key: String, result: Long = 0): Long = sp.getLong(key, result)

然后我将包名整理一下:目前的项目目录如下图所示:

应该很清晰吧,下面进行操作,首先是保存记录,因此我需要弄两个常量,就在BleConstant中增加好了。增加如下代码:

代码语言:javascript复制
		/**
         * 是否过滤设备名称为Null的设备
         */
        const val NULL_NAME = "nullName"

        /**
         * 过滤信号强度值
         */
        const val RSSI = "rssi"

下面回到MainActivity中,首先是对变量的控制,缓存数据也就两种操作方式,存和取。作为一个开关值那么就是true和false。当没有这个缓存的时候默认为false。当然也可以是true,根据实际需求来。那么这个缓存值的设置就在弹窗中的swich的操作时改变。代码如下:

上图中有两处地方用到了这个常量值NULL_NAME,一个是存一个是取,这里还只是针对于弹窗中的设置和显示效果的不同,还没有对实际的扫描结果进行处理的,这个代码应该是很好理解的。下面是RSSI的值的存取。代码如下图所示:

这里当拖动Seekbar时,改变TextView显示的内容,当拖动结束时保存进度值到缓存中,然后处理弹窗这个窗时的页面显示状态,我这里通过getInt(RSSI,100)去获取本地的缓存,如果没有就设置为100,在扩展函数中我设置的缺省值是0,你也可以设置为100,则使用的地方就不需要增加这个默认参数了。例如我上次滑动到50,然后我关闭了弹窗,当我再次打开弹窗时应该也是要显示50的,那么对于本地缓存ui的控制效果演示图如下图所示:

下面就可以对扫描到的设备进行操作了,因为扫描过滤的设置已经没有问题了,开发时要注意的细节很多。

⑤ 扫描设备过滤

  开发是循序渐进的,逻辑很重要,先想清楚逻辑再进行编码这会让你事倍功半。下面就是对扫描的结果进行处理,针对于一些结果可以不用添加到设备列表中,因此就不会显示了。这里需要两个临时变量去控制。在MainActivity中增加如下代码:

代码语言:javascript复制
	//当前扫描设备是否过滤设备名称为Null的设备
    private var isScanNullNameDevice = false
    
    //当前扫描设备是否过滤设备信号值强度低于目标值的设备
    private var rssi = -100

然后对扫描到的设备进行处理

然后是addDeviceList中的代码修改。

这里的代码就是对修改了扫描过滤中数据的处理,然后就是过滤设备列表,这里是一个方法,代码如下:

代码语言:javascript复制
	/**
     * 过滤设备列表
     */
    private fun filterDeviceList() {
        if (mList.size > 0) {
            val mIterator = mList.iterator()
            while (mIterator.hasNext()) {
                val next = mIterator.next()
                if ((getBoolean(NULL_NAME) && next.device.name == null) || next.rssi < rssi) {
                    addressList.remove(next.device.address)
                    mIterator.remove()
                }
            }
            bleAdapter.notifyDataSetChanged()
        }
    }

通过迭代的方式对符合清除条件的设备进行移除,同时也要移除地址列表中的地址。

下面运行一下:

二、自定义服务与特性

  这个功能的出发点就在于,当需要操作不同的低功耗蓝牙设备时,对应的设备需要使用对应的服务UUID和特性UUID,因此需要自定义这个服务与特性,做成可以动态设置的,这里依然采用弹窗来设置服务与特性。

① 弹窗设置

首先在layout下新增一个dialog_uuid_edit.xml,布局代码如下:

代码语言:javascript复制
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:background="@drawable/shape_white_top_radius_24"
    android:layout_height="wrap_content">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <TextView
            android:layout_width="match_parent"
            android:layout_height="60dp"
            android:gravity="center"
            android:text="UUID设置"
            android:textColor="@color/black"
            android:textSize="16sp" />

        <TextView
            android:id="@ id/tv_save"
            android:padding="12dp"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentEnd="true"
            android:layout_centerVertical="true"
            android:layout_marginEnd="8dp"
            android:text="保存"
            android:textColor="@color/green"
            android:textSize="16sp" />
    RelativeLayout>

    <View
        android:layout_width="match_parent"
        android:layout_height="0.5dp"
        android:background="@color/line" />

    <com.google.android.material.textfield.TextInputLayout
        android:layout_width="match_parent"
        android:layout_height="50dp">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@ id/et_service_uuid"
            android:background="@color/white"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:hint="SERVICE UUID"
            android:inputType="text|textCapCharacters"
            android:textColor="@color/black"
            android:textSize="12sp" />
    com.google.android.material.textfield.TextInputLayout>

    <com.google.android.material.textfield.TextInputLayout
        android:layout_width="match_parent"
        android:layout_height="50dp">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@ id/et_descriptor_uuid"
            android:background="@color/white"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:hint="DESCRIPTOR UUID"
            android:inputType="text|textCapCharacters"
            android:textColor="@color/black"
            android:textSize="12sp" />
    com.google.android.material.textfield.TextInputLayout>

    <com.google.android.material.textfield.TextInputLayout
        android:layout_width="match_parent"
        android:layout_height="50dp">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@ id/et_characteristic_write_uuid"
            android:background="@color/white"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:hint="CHARACTERISTIC WRITE UUID"
            android:inputType="text|textCapCharacters"
            android:textColor="@color/black"
            android:textSize="12sp" />
    com.google.android.material.textfield.TextInputLayout>

    <com.google.android.material.textfield.TextInputLayout
        android:layout_width="match_parent"
        android:layout_height="50dp">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@ id/et_characteristic_indicate_uuid"
            android:background="@color/white"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:hint="CHARACTERISTIC INDICATE UUID"
            android:inputType="text|textCapCharacters"
            android:textColor="@color/black"
            android:textSize="12sp" />
    com.google.android.material.textfield.TextInputLayout>

    <View
        android:layout_width="match_parent"
        android:layout_height="0.5dp"
        android:background="@color/line" />

    <TextView
        android:id="@ id/tv_close"
        android:layout_width="match_parent"
        android:layout_height="60dp"
        android:foreground="?attr/selectableItemBackground"
        android:gravity="center"
        android:text="关闭"
        android:textColor="@color/black" />

LinearLayout>

② 显示弹窗

编辑弹窗代码:

代码语言:javascript复制
	/**
     * uuid编辑弹窗
     */
    private fun showUuidEditDialog() = BottomSheetDialog(this,R.style.BottomSheetDialogStyle).apply {
            setContentView(DialogUuidEditBinding.bind(View.inflate(context, R.layout.dialog_uuid_edit,null)).apply {
                tvSave.setOnClickListener {
                    etServiceUuid.text.toString().apply { if (isNotEmpty()) putString(BleConstant.SERVICE_UUID) }
                    etDescriptorUuid.text.toString().apply { if (isNotEmpty()) putString(BleConstant.DESCRIPTOR_UUID) }
                    etCharacteristicWriteUuid.text.toString().apply { if (isNotEmpty()) putString(BleConstant.CHARACTERISTIC_WRITE_UUID) }
                    etCharacteristicIndicateUuid.text.toString().apply { if (isNotEmpty()) putString(BleConstant.CHARACTERISTIC_INDICATE_UUID) }
                    dismiss()
                }
                tvClose.setOnClickListener { dismiss() }
                //显示之前设置过的uuid
                etServiceUuid.setText(getString(BleConstant.SERVICE_UUID))
                etDescriptorUuid.setText(getString(BleConstant.DESCRIPTOR_UUID))
                etCharacteristicWriteUuid.setText(getString(BleConstant.CHARACTERISTIC_WRITE_UUID))
                etCharacteristicIndicateUuid.setText(getString(BleConstant.CHARACTERISTIC_INDICATE_UUID))
            }.root)
        window?.findViewById<View>(R.id.design_bottom_sheet)?.setBackgroundColor(Color.TRANSPARENT)
    }.show()

③ 添加菜单

  这里我会改变之前的菜单,因为考虑到扫描过滤可能是一个常用的菜单,最好的方式就是页面直接可见,而不是先点了菜单键再去找这个扫描过滤菜单。这里会用到一个扫描图标ic_scan_filter.png,可以去我的源码中去找。修改main_menu.xml代码如下所示:

代码语言:javascript复制
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">
    <item
        android:id="@ id/item_scan_filter"
        android:title="扫描过滤"
        app:showAsAction="ifRoom"
        android:icon="@mipmap/ic_scan_filter"
        tools:ignore="HardcodedText" />
    <item
        android:id="@ id/item_service_characteristics"
        android:title="服务特性"
        app:showAsAction="never"
        tools:ignore="HardcodedText" />
menu>

预览图效果如下,可以看到扫描过滤直接显示一个图标按钮,点击之后就可以显示扫描过滤弹窗了。

下面回到MainActivity中,修改代码如下:

运行效果图如下:

下面修改BleConstant中的常量。

因为现在是动态的设置服务与特性了,就不能再向之前直接写在常量里面了。

④ UUID检查

  在之前的代码中,是扫描到设备后点击设备进入另一个页面中进行数据的交互,此时就会涉及到uuid,之前的uuid是写死的,不需要考虑这个问题,而现在uuid是动态设置的,因此在你没有设置uuid时不能对设备进行相应的操作。当然了连接设备不需要uuid,不过发现服务和读写特性就需要了。为了预防万一还是希望在连接前用户就将uuid都设置好,这样后面的操作就没有什么顾虑了。

下面进行uuid的检查,MainActivity中新增方法,代码如下:

代码语言:javascript复制
	/**
     * 检查UUID
     */
    private fun checkUuid(): Boolean {
        val serviceUuid = getString(BleConstant.SERVICE_UUID)
        val descriptorUuid = getString(BleConstant.DESCRIPTOR_UUID)
        val writeUuid = getString(BleConstant.CHARACTERISTIC_WRITE_UUID)
        val indicateUuid = getString(BleConstant.CHARACTERISTIC_INDICATE_UUID)
        if (serviceUuid.isNullOrEmpty()) {
            showMsg("请输入Service UUID")
            return false
        }
        if (serviceUuid.length < 32) {
            showMsg("请输入正确的Service UUID")
            return false
        }
        if (descriptorUuid.isNullOrEmpty()) {
            showMsg("请输入Descriptor UUID")
            return false
        }
        if (descriptorUuid.length < 32) {
            showMsg("请输入正确的Descriptor UUID")
            return false
        }
        if (writeUuid.isNullOrEmpty()) {
            showMsg("请输入Characteristic Write UUID")
            return false
        }
        if (writeUuid.length < 32) {
            showMsg("请输入正确的Characteristic Write UUID")
            return false
        }
        if (indicateUuid.isNullOrEmpty()) {
            showMsg("请输入Characteristic Indicate UUID")
            return false
        }
        if (indicateUuid.length < 32) {
            showMsg("请输入正确的Characteristic Indicate UUID")
            return false
        }
        return true
    }

然后在点击设备之前先进行检查

只有为true的时候才会进入连接的操作,则需要通过检查才会为true,如下图这样设置。

本文的代码就写完了。

三、源码

GitHub: BleDemo-Kotlin

0 人点赞