Android Ble蓝牙App(一)扫描(上)

简介: Android Ble蓝牙App(一)扫描(上)

前言


  关于低功耗的蓝牙介绍我已经做过很多了,只不过很多人不是奔着学习的目的去的,拿着源码就去运行,后面又发现连接设备后马上断开,然后不会自己看问题,这个现象就是快餐式的,你不了解里面的知识内容,自然就不知道是怎么回事,重复的问题我回答了好多次了。而我也是觉得写的有问题,本意上来说我是希望读者可以参考来写,能看一看文章内容,而结果绝大多数,看个标题看个运行效果,下载源码就运行,运行有问题就问你,没有什么思考。

  针对这个情况,我决定做了系列性的Ble蓝牙App,尽可能的避免在你运行的时候出现bug,所以这是一个低功耗蓝牙工具App,可以让你了解到一些东西。注意是低功耗,不是经典蓝牙,如果你不知道两者之间的区别,建议你先了解一下。本文的效果:

51434893b9ee48938c30dc842ec50f2e.gif


正文


  本文将会重新创建一个项目,功能一个一个的做,尽量的做好每一个功能的优化,下面我们创建一个名为GoodBle的项目,语言为Kotlin。

fc203a1435c64a9ab7d5728a6f1e4be1.png

  至于为什么使用Kotlin,稳固一下,不然太久不用就会生疏,文本我们讲述的是扫描,你可能回想,一个扫描有什么好写,不就是开始、结束、显示设备嘛?至于单独作为一个功能来写一篇文章嘛?那么我们带着问题来看这篇文章,看看扫描到底有没有必要这样来做。


一、基本配置


  当前我们创建项目有一个MainActivity,然后我们需要打开viewBinding的开关,在app的build.gradle中的android{}闭包中添加如下代码:

  buildFeatures {
        viewBinding true
    }


然后Sync Now,同步一下,开启成功。随后我们就可以在Activity中使用ViewBinding了,常规的使用方式是这样的:

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding;
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater);
        setContentView(binding.root)
    }
}


  在Java中封装通常采用反射的方式,在Kotlin中如果要对ViewBinding进行封装的话同时利用上Kotlin的一些特性的话,可以这样做,原文地址如下:Viewbinding使用和委托封装,感觉写得蛮好的,太久没用Kotlin了,还是看了一会才看懂,感兴趣的可以看看。

  那么我们在com.llw.goodble下面创建一个base包,base包下创建BaseViewBinding.kt文件,里面的代码如下所示:

package com.llw.goodble.base
import android.app.Activity
import android.view.LayoutInflater
import androidx.viewbinding.ViewBinding
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
fun <VB : ViewBinding> viewBinding(viewInflater: (LayoutInflater) -> VB):
        ReadOnlyProperty<Activity, VB> = ActivityViewBindingProperty(viewInflater)
class ActivityViewBindingProperty<VB : ViewBinding>(
    private val viewInflater: (LayoutInflater) -> VB
) : ReadOnlyProperty<Activity, VB> {
    private var binding: VB? = null
    override fun getValue(thisRef: Activity, property: KProperty<*>): VB {
        return binding ?: viewInflater(thisRef.layoutInflater).also {
            thisRef.setContentView(it.root)
            binding = it
        }
    }
}


通过委托的方式进行封装,下面来看在MainActivity中怎么使用它,

class MainActivity : AppCompatActivity() {
    private val binding by viewBinding(ActivityMainBinding::inflate)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}


  使用起来很简单,后面就采用这种方式,你可以运行一下,看看有没有问题,然后我们可以再创建一个ScanActivity类,用于扫描页面,修改一下activity_main.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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="match_parent"
    tools:context=".MainActivity">
    <com.google.android.material.appbar.MaterialToolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:background="@color/orange"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navigationIcon="@drawable/ic_scan_ble"
        app:title="GoodBle"
        app:titleCentered="true"
        app:titleTextColor="@color/white" />
</androidx.constraintlayout.widget.ConstraintLayout>


这里用到了图标,代码如下所示:

<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="32dp"
    android:height="32dp"
    android:viewportWidth="1024"
    android:viewportHeight="1024">
    <path
        android:fillColor="#ffffff"
        android:pathData="M761.5,141.1c-14.3,-9.6 -33.6,-5.9 -43.2,8.4 -9.6,14.2 -5.9,33.6 8.4,43.2 106,71.6 169.3,190.7 169.3,318.4 0,211.7 -172.2,384 -384,384S128,722.9 128,511.1c0,-127.8 63.3,-246.8 169.3,-318.4 14.2,-9.6 18,-29 8.4,-43.2s-29,-18 -43.2,-8.4C139.3,224.4 65.7,362.7 65.7,511.1c0,246.1 200.2,446.2 446.2,446.2S958.2,757.2 958.2,511.1C958.2,362.7 884.6,224.4 761.5,141.1z" />
    <path
        android:fillColor="#ffffff"
        android:pathData="M402.1,157.6c17.2,0 31.1,-13.9 31.1,-31.1L433.2,96c0,-17.2 -13.9,-31.1 -31.1,-31.1s-31.1,13.9 -31.1,31.1l0,30.4C371,143.6 384.9,157.6 402.1,157.6z" />
    <path
        android:fillColor="#ffffff"
        android:pathData="M624.3,157.6c17.2,0 31.1,-13.9 31.1,-31.1L655.5,96c0,-17.2 -13.9,-31.1 -31.1,-31.1s-31.1,13.9 -31.1,31.1l0,30.4C593.2,143.6 607.1,157.6 624.3,157.6z" />
    <path
        android:fillColor="#ffffff"
        android:pathData="M428.3,227.4c11.2,18 41.8,48.4 85.9,48.4 43.8,0 74.9,-30.2 86.3,-48.1 9.3,-14.5 5.1,-33.7 -9.4,-43 -14.5,-9.3 -33.7,-5 -43,9.4 -0.1,0.2 -13.3,19.4 -33.9,19.4 -19.9,0 -32.3,-18 -33.2,-19.3 -9.1,-14.4 -28.2,-18.7 -42.7,-9.7C423.7,193.6 419.2,212.8 428.3,227.4z" />
    <path
        android:fillColor="#ffffff"
        android:pathData="M306,440.9c-9.2,14.5 -4.8,33.8 9.7,42.9l142.7,90.1L314.1,665.1c-14.5,9.2 -18.9,28.4 -9.7,42.9 5.9,9.4 16,14.5 26.3,14.5 5.7,0 11.4,-1.6 16.6,-4.8l135.7,-85.7 0,148c0,10.6 4,20.2 10.3,27.8 0.4,0.5 0.8,1 1.2,1.4 8.4,9.3 20.5,15.3 34.1,15.3 2.4,0 4.8,-0.3 7,-0.9 5.8,-0.9 11.4,-2.8 16.5,-5.8 0.8,-0.5 1.6,-1 2.3,-1.5l134,-96.2c12.7,-8.2 20.5,-22.2 20.6,-37.2 0,-15.5 -8.4,-30.1 -21.2,-37.7l-113,-71.4 110.6,-69.9c13.6,-8.1 22,-22.8 21.9,-38.3 -0.1,-15 -8,-29 -20.7,-37.1l-132.4,-94.4c-0.8,-0.6 -1.6,-1.1 -2.5,-1.6 -21,-12.1 -47.9,-6.1 -61.4,13.7 -2.5,3.7 -4.1,7.8 -4.8,11.9 -1.7,3.9 -2.7,8.1 -2.7,12.7l0,144.9 -134.1,-84.7C334.4,422 315.2,426.4 306,440.9zM545.3,746.4 L545.3,628.9l87.1,55L545.3,746.4zM630.7,465.1l-85.4,53.9L545.3,404.3 630.7,465.1z" />
</vector>


颜色值,在colors.xml中增加:

  <color name="orange">#FF5722</color>
    <color name="warm_yellow">#FFC107</color>
    <color name="dark_orange">#FF9800</color>
    <color name="light_orange">#FFF3E0</color>
    <color name="gray_white">#F8F8F8</color>
    <color name="gray">#989898</color>


  这里给toolbar设置导航图标,点击这个导航到扫描页面,不过再次之前我们可以在base包下再创建一个BaseActivity,这里面可以写一些常用的函数,代码如下所示:

open class BaseActivity : AppCompatActivity() {
    private var context: Context? = null
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        context = this
    }
    protected fun jumpActivity(clazz: Class<*>?, finish: Boolean = false) {
        startActivity(Intent(context, clazz))
        if (finish) finish()
    }
    protected fun back(toolbar: Toolbar, finish: Boolean = false) =
        toolbar.setNavigationOnClickListener { if (finish) finish() else onBackPressed() }
    protected fun showMsg(msg: CharSequence) =
        Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
  protected open fun isAndroid12() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
    protected open fun hasAccessFineLocation() =
        hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)
    protected open fun hasCoarseLocation() =
        hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION)
    @RequiresApi(Build.VERSION_CODES.S)
    protected open fun hasBluetoothConnect() = hasPermission(Manifest.permission.BLUETOOTH_CONNECT)
    @RequiresApi(Build.VERSION_CODES.S)
    protected open fun hasBluetoothScan() = hasPermission(Manifest.permission.BLUETOOTH_SCAN)
    /**
     * 检查是有拥有某权限
     *
     * @param permission 权限名称
     * @return true 有  false 没有
     */
    protected open fun hasPermission(permission: String) = checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
    /**
     * 蓝牙是否打开
     *
     * @return true or false
     */
    protected open fun isOpenBluetooth(): Boolean {
        (getSystemService(BLUETOOTH_SERVICE) as BluetoothManager).also {
            it.adapter ?: return false
            return it.adapter.isEnabled
        }
    }
    /**
     * 位置是否打开
     */
    protected open fun isOpenLocation(): Boolean {
        val locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
        val gps = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
        val network = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
        val locationEnabled = isLocationEnabled()
        Log.d("TAG", "gps: $gps,network:$network,locationEnabled:$locationEnabled")
        return gps || network || locationEnabled
    }
    open fun isLocationEnabled(): Boolean {
        val locationMode = try {
            Settings.Secure.getInt(contentResolver, Settings.Secure.LOCATION_MODE)
        } catch (e: SettingNotFoundException) {
            e.printStackTrace()
            return false
        }
        return locationMode != Settings.Secure.LOCATION_MODE_OFF
    }
}


  这里面就是一些比较基础的方法,在后面扫描页面会用到的,然后再修改一下MainActivity中的代码,继承BaseActivity,点击中跳转扫描页面:

class MainActivity : BaseActivity() {
    private val binding by viewBinding(ActivityMainBinding::inflate)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        binding.toolbar.setNavigationOnClickListener { jumpActivity(ScanActivity::class.java) }
    }
}


为了保持一样的UI效果,下面更改一下themes.xml中的代码,如下所示:

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.GoodBle" parent="Theme.MaterialComponents.DayNight.NoActionBar">
        <!-- Primary brand color. -->
        <item name="colorPrimary">@color/orange</item>
        <item name="colorPrimaryVariant">@color/orange</item>
        <item name="colorOnPrimary">@color/white</item>
        <!-- Secondary brand color. -->
        <item name="colorSecondary">@color/light_orange</item>
        <item name="colorSecondaryVariant">@color/dark_orange</item>
        <item name="colorOnSecondary">@color/white</item>
        <!-- Status bar color. -->
        <item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
        <!-- Customize your theme here. -->
        <item name="android:windowBackground">@color/gray_white</item>
    </style>
    <style name="BottomSheetDialogStyle" parent="Theme.Design.BottomSheetDialog">
        <item name="android:windowFrame">@null</item>
        <item name="android:windowIsFloating">true</item>
        <item name="android:windowIsTranslucent">true</item>
        <item name="android:background">@android:color/transparent</item>
        <item name="android:backgroundDimEnabled">true</item>
        <item name="android:colorBackground">@android:color/transparent</item>
    </style>
</resources>


  主要就是修改状态栏颜色,窗口默认背景颜色,现在前置的条件都准备的差不多了,运行一下看看MainActivity的页面效果。

99412eaf0f3f402fb95a10ac35b7ecaf.png


二、扫描准备


  下面在com.llw.goodble包下新建一个ble包,里面我们需要创建一些类来处理扫描的相关事务,首先在ble包下创建一个BleCore类,里面先不写内容,然后我们在ble包下新建一个scan包。在scan包下新建一个BleScanCallback接口,这是一个扫描回调接口,代码如下所示:

interface BleScanCallback {
    /**
     * 扫描结果
     */
    fun onScanResult(result: ScanResult)
    /**
     * 批量扫描结果
     */
    fun onBatchScanResults(results: List<ScanResult>) {}
    /**
     * 扫描错误
     */
    fun onScanFailed(failed: String) {}
}


同时在扫描页面需要监听一下蓝牙和定位是否打开,在scan包下添加一个广播接收器的ReceiverCallback 接口,代码如下所示:

interface ReceiverCallback {
    /**
     * 蓝牙关闭
     */
    fun bluetoothClose()
    /**
     * 位置关闭
     */
    fun locationClose()
}


下面在scan创建广播接收器ScanReceiver,代码如下所示:

class ScanReceiver : BroadcastReceiver() {
    private var callback: ReceiverCallback? = null
    fun setCallback(callback: ReceiverCallback?) {
        this.callback = callback
    }
    private var isSend = 0
    override fun onReceive(context: Context, intent: Intent) {
        val action = intent.action
        if (action == BluetoothAdapter.ACTION_STATE_CHANGED) {
            when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)) {
                BluetoothAdapter.STATE_OFF -> Log.d(TAG, "STATE_OFF Phone bluetooth off")
                BluetoothAdapter.STATE_TURNING_OFF -> {
                    callback!!.bluetoothClose()
                    Log.d(TAG, "STATE_TURNING_OFF Phone bluetooth is turning off")
                }
                BluetoothAdapter.STATE_ON -> Log.d(TAG, "STATE_ON Phone bluetooth turned on")
                BluetoothAdapter.STATE_TURNING_ON -> Log.d(TAG, "STATE_TURNING_ON Phone bluetooth is on")
            }
        } else if (action == LocationManager.PROVIDERS_CHANGED_ACTION) {
            if (!isGPSOpen(context)) {
                isSend++
                if (isSend == 1) {
                    Log.d(TAG, "Positioning off")
                    callback!!.locationClose()
                } else if (isSend == 4) {
                    isSend = 0
                }
            }
        }
    }
    companion object {
        val TAG: String = ScanReceiver::class.java.simpleName
        fun isGPSOpen(context: Context): Boolean {
            val locationMode = try {
                Settings.Secure.getInt(context.contentResolver, Settings.Secure.LOCATION_MODE)
            } catch (e: SettingNotFoundException) {
                e.printStackTrace()
                return false
            }
            return locationMode != Settings.Secure.LOCATION_MODE_OFF
        }
    }
}


  这里的代码相对简单就是广播接收器接收相关的动作信息,再进行回调,然后我们写一个用于扫描类,在scan包下新建一个BleScan类,代码如下所示:

/**
 * 低功耗扫描类
 */
@SuppressLint("MissingPermission", "InlinedApi")
class BleScan private constructor(private val context: Context) {
    private var mScanFilters: List<ScanFilter>
    private var mScanSettings: ScanSettings
    private var bleScanCallback: BleScanCallback? = null
    var mIsScanning = false
    init {
        mScanFilters = ArrayList()
        mScanSettings = ScanSettings.Builder().build()
    }
    companion object {
        @SuppressLint("StaticFieldLeak")
        @Volatile
        private var instance: BleScan? = null
        private var mBluetoothAdapter: BluetoothAdapter? = null
        private var mScanner: BluetoothLeScanner? = null
        fun getInstance(context: Context) = instance ?: synchronized(this) {
            instance ?: BleScan(context).also {
                instance = it
                val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
                mBluetoothAdapter = manager.adapter
                if (mBluetoothAdapter != null) {
                    mScanner = mBluetoothAdapter?.bluetoothLeScanner
                }
            }
        }
    }
    /**
     * 设置扫描过滤
     */
    fun setScanFilters(scanFilters: List<ScanFilter>) {
        mScanFilters = scanFilters
    }
    /**
     * 设置扫描设置选项
     */
    fun setScanSettings(scanSettings: ScanSettings) {
        mScanSettings = scanSettings
    }
    /**
     * 设置扫描回调
     */
    fun setPhyScanCallback(bleScanCallback: BleScanCallback?) {
        this.bleScanCallback = bleScanCallback
    }
    fun isScanning() = mIsScanning
    /**
     * 扫描回调
     */
    private val scanCallback: ScanCallback = object : ScanCallback() {
        override fun onScanResult(callbackType: Int, result: ScanResult) {
            bleScanCallback?.onScanResult(result)
        }
        override fun onBatchScanResults(results: List<ScanResult>) {
            bleScanCallback?.onBatchScanResults(results)
        }
        override fun onScanFailed(errorCode: Int) {
            localScanFailed(
                when (errorCode) {
                    SCAN_FAILED_ALREADY_STARTED -> "Fails to start scan as BLE scan with the same settings is already started by the app."
                    SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "Fails to start scan as app cannot be registered."
                    SCAN_FAILED_INTERNAL_ERROR -> "Fails to start scan due an internal error"
                    SCAN_FAILED_FEATURE_UNSUPPORTED -> "Fails to start power optimized scan as this feature is not supported."
                    else -> "UNKNOWN_ERROR"
                }
            )
        }
    }
    /**
     * 显示本地扫描错误
     */
    private fun localScanFailed(failed: String) = bleScanCallback?.onScanFailed(failed)
    /**
     * 开始扫描
     */
    @SuppressLint("MissingPermission")
    fun startScan() {
        if (!isOpenBluetooth()) {
            localScanFailed("Bluetooth is not turned on.")
            return
        }
        if (isAndroid12()) {
            if (!hasBluetoothScan()) {
                localScanFailed("Android 12 needs to dynamically request bluetooth scan permission.")
                return
            }
        } else {
            if (!hasAccessFineLocation()) {
                localScanFailed("Android 6 to 12 requires dynamic request location permission.")
                return
            }
        }
        if (mIsScanning) {
            localScanFailed("Currently scanning, please close the current scan and scan again.")
            return
        }
        if (mScanner == null) mScanner = mBluetoothAdapter?.bluetoothLeScanner
        if (!mBluetoothAdapter!!.isEnabled) {
            localScanFailed("Bluetooth not turned on.")
            return
        }
        mScanner?.startScan(mScanFilters, mScanSettings, scanCallback)
        mIsScanning = true
    }
    /**
     * 停止扫描
     */
    fun stopScan() {
        if (!mIsScanning) {
            localScanFailed("Not currently scanning, your stop has no effect.")
            return
        }
        if (mScanner == null) {
            localScanFailed("BluetoothLeScanner is Null.")
            return
        }
        if (!mBluetoothAdapter!!.isEnabled) {
            localScanFailed("Bluetooth not turned on.")
            return
        }
        mIsScanning = false
        mScanner?.stopScan(scanCallback)
    }
    /**
     * 是否打开蓝牙
     */
    private fun isOpenBluetooth() = if (mBluetoothAdapter == null) {
        localScanFailed("BluetoothAdapter is Null."); false
    } else mBluetoothAdapter!!.isEnabled
    private fun isAndroid12() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
    private fun hasAccessFineLocation() = hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)
    private fun hasBluetoothConnect() = hasPermission(Manifest.permission.BLUETOOTH_CONNECT)
    private fun hasBluetoothScan() = hasPermission(Manifest.permission.BLUETOOTH_SCAN)
    private fun hasPermission(permission: String) = context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
}


  这里首先是创建一个单例,在里面对当前类和一些变量进行初始化,核心就是一个扫描回调,开始和停止扫描的方法处理。因为后面还需要写Ble相关的数据处理,因此在ble包下创建一个BleCore类,代码如下所示:

class BleCore private constructor(private val context: Context) {
    @SuppressLint("StaticFieldLeak")
    companion object {
        @SuppressLint("StaticFieldLeak")
        @Volatile
        private var instance: BleCore? = null
        @SuppressLint("StaticFieldLeak")
        private lateinit var bleScan: BleScan
        fun getInstance(context: Context) = instance ?: synchronized(this) {
            instance ?: BleCore(context).also {
                instance = it
                //蓝牙扫描
                bleScan = BleScan.getInstance(context)
            }
        }
    }
    fun setPhyScanCallback(bleScanCallback: BleScanCallback) {
        bleScan.setPhyScanCallback(bleScanCallback)
    }
    fun isScanning() = bleScan.isScanning()
    fun startScan() = bleScan.startScan()
    fun stopScan() = bleScan.stopScan()
}


  同样是一个单例,在里面初始化BleScan,然后增加几个函数去调用BleScan中的函数,最后我们在com.llw.goodble包下创建一个BleApp类,代码如下所示:

class BleApp : Application() {
    @SuppressLint("StaticFieldLeak")
    private lateinit var context: Context
    @SuppressLint("StaticFieldLeak")
    private lateinit var bleCore: BleCore
    override fun onCreate() {
        super.onCreate()
        context = applicationContext
        //初始化Ble核心库
        bleCore = BleCore.getInstance(this)
    }
    fun getBleCore() = bleCore
}


 这里继承Application,通过自定义的方式在App启动的时候加载这个类,然后在onCreate()函数中,完成对于Ble核心类的初始化,顺便完成对于Ble扫描类的初始化。最后在AndroidManifest.xml中的application标签中配置这个BleApp,如下所示:

    <application
        android:name=".BleApp"
        ...>


三、扫描页面


  在Android12及以上版本,使用蓝牙时需要请求扫描、连接权限、如果还需要使用手机作为从机的话,就请求广播权限,后面会提到的,同时在低版本Android中我们扫描蓝牙请求定位权限,那么首先我们就把权限的部分先做了。

首先声明静态权限,在AndroidManifest.xml中增加如下代码:

    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.BLUETOOTH" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
    <uses-feature android:name="android.hardware.bluetooth_le"/>


① 增加UI布局

  动态权限请求有两种方式,一种是进入这个页面一下子请求多个权限,另一种是一个一个来请求,让你知道为什么会请求这个权限,这里我们选择第二种,因此需要增加一些布局xml,如下图所示的布局XML。

77d4b363b1f64b089ee3ff8548195399.png

下面我们依次创建,lay_android12_should_connect.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/request_location_lay"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/gray_white"
    android:gravity="center"
    android:orientation="vertical"
    android:paddingStart="16dp"
    android:paddingEnd="16dp">
    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:padding="16dp"
        android:src="@drawable/ic_bluetooth_connected" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="需要蓝牙连接权限"
        android:textColor="@color/dark_orange"
        android:textSize="16sp" />
    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:layout_marginBottom="16dp"
        android:text="从Android12.0开始,打开蓝牙之前需要请求此权限,使用蓝牙连接权限"
        app:titleTextColor="@color/black" />
    <com.google.android.material.button.MaterialButton
        android:id="@+id/btn_request_connect_permission"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="授予权限"
        android:textColor="@color/white" />
</LinearLayout>


用到一个图标ic_bluetooth_connected.xml

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="48dp"
    android:height="48dp"
    android:tint="@color/orange"
    android:viewportWidth="24.0"
    android:viewportHeight="24.0">
    <path
        android:fillColor="@android:color/white"
        android:pathData="M7,12l-2,-2 -2,2 2,2 2,-2zM17.71,7.71L12,2h-1v7.59L6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 11,14.41L11,22h1l5.71,-5.71 -4.3,-4.29 4.3,-4.29zM13,5.83l1.88,1.88L13,9.59L13,5.83zM14.88,16.29L13,18.17v-3.76l1.88,1.88zM19,10l-2,2 2,2 2,-2 -2,-2z" />
</vector>


布局lay_android12_should_scan.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/request_location_lay"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/gray_white"
    android:gravity="center"
    android:orientation="vertical"
    android:paddingStart="16dp"
    android:paddingEnd="16dp">
    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:padding="16dp"
        android:src="@drawable/ic_bluetooth_scan" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="需要扫描权限"
        android:textColor="@color/dark_orange"
        android:textSize="16sp" />
    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:layout_marginBottom="16dp"
        android:text="从Android12.0开始,扫描设备不再需要请求定位权限,使用此权限"
        app:titleTextColor="@color/black" />
    <com.google.android.material.button.MaterialButton
        android:id="@+id/btn_request_scan_permission"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="授予权限"
        android:textColor="@color/white" />
</LinearLayout>


图标ic_bluetooth_scan.xml

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="48dp"
    android:height="48dp"
    android:autoMirrored="true"
    android:tint="@color/orange"
    android:viewportWidth="24.0"
    android:viewportHeight="24.0">
    <path
        android:fillColor="@android:color/white"
        android:pathData="M14.24,12.01l2.32,2.32c0.28,-0.72 0.44,-1.51 0.44,-2.33 0,-0.82 -0.16,-1.59 -0.43,-2.31l-2.33,2.32zM19.53,6.71l-1.26,1.26c0.63,1.21 0.98,2.57 0.98,4.02s-0.36,2.82 -0.98,4.02l1.2,1.2c0.97,-1.54 1.54,-3.36 1.54,-5.31 -0.01,-1.89 -0.55,-3.67 -1.48,-5.19zM15.71,7.71L10,2L9,2v7.59L4.41,5 3,6.41 8.59,12 3,17.59 4.41,19 9,14.41L9,22h1l5.71,-5.71 -4.3,-4.29 4.3,-4.29zM11,5.83l1.88,1.88L11,9.59L11,5.83zM12.88,16.29L11,18.17v-3.76l1.88,1.88z" />
</vector>


布局lay_empty.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical">
    <ImageView
        android:id="@+id/imageView"
        android:layout_width="100dp"
        android:layout_height="100dp"
        app:srcCompat="@mipmap/ic_scanning" />
    <TextView
        android:id="@+id/textView2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:text="努力扫描中"
        android:textColor="@color/gray"
        android:textSize="18sp"
        android:textStyle="bold" />
</LinearLayout>


  图标不是XML图片,去源码中获取,这是在扫描不到设备的时候显示的布局,布局lay_should_enable_bluetooth.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/gray_white"
    android:gravity="center"
    android:orientation="vertical">
    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="16dp"
        app:srcCompat="@drawable/ic_bluetooth_disabled" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:gravity="center_horizontal"
        android:text="蓝牙已禁用"
        android:textColor="@color/dark_orange"
        android:textSize="16sp" />
    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:gravity="center_horizontal"
        android:text="蓝牙适配器已关闭,单击下面的按钮以启用它。"
        android:textColor="@color/black" />
    <com.google.android.material.button.MaterialButton
        android:id="@+id/btn_enable_bluetooth"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="16dp"
        android:text="启用"
        android:textColor="@color/white" />
</LinearLayout>


图标ic_bluetooth_disabled.xml

<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="48dp"
    android:height="48dp"
    android:viewportWidth="24.0"
    android:viewportHeight="24.0">
    <path
        android:fillColor="@color/orange"
        android:pathData="M13,5.83l1.88,1.88 -1.6,1.6 1.41,1.41 3.02,-3.02L12,2h-1v5.03l2,2v-3.2zM5.41,4L4,5.41 10.59,12 5,17.59 6.41,19 11,14.41V22h1l4.29,-4.29 2.3,2.29L20,18.59 5.41,4zM13,18.17v-3.76l1.88,1.88L13,18.17z" />
</vector>


布局lay_should_enable_location.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/gray_white"
    android:gravity="center"
    android:orientation="vertical">
    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:padding="16dp"
        app:srcCompat="@drawable/ic_location_disabled" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="位置已禁用"
        android:textColor="@color/dark_orange"
        android:textSize="16sp" />
    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:gravity="center_horizontal"
        android:text="位置已关闭,单击下面的按钮以启用它。"
        android:textColor="@color/black" />
    <com.google.android.material.button.MaterialButton
        android:id="@+id/btn_enable_location"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="16dp"
        android:text="启用"
        android:textColor="@color/white" />
</LinearLayout>


图标ic_location_disabled.xml

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="48dp"
    android:height="48dp"
    android:tint="@color/orange"
    android:viewportWidth="24.0"
    android:viewportHeight="24.0">
    <path
        android:fillColor="@android:color/white"
        android:pathData="M20.94,11c-0.46,-4.17 -3.77,-7.48 -7.94,-7.94L13,1h-2v2.06c-1.13,0.12 -2.19,0.46 -3.16,0.97l1.5,1.5C10.16,5.19 11.06,5 12,5c3.87,0 7,3.13 7,7 0,0.94 -0.19,1.84 -0.52,2.65l1.5,1.5c0.5,-0.96 0.84,-2.02 0.97,-3.15L23,13v-2h-2.06zM3,4.27l2.04,2.04C3.97,7.62 3.25,9.23 3.06,11L1,11v2h2.06c0.46,4.17 3.77,7.48 7.94,7.94L11,23h2v-2.06c1.77,-0.2 3.38,-0.91 4.69,-1.98L19.73,21 21,19.73 4.27,3 3,4.27zM16.27,17.54C15.09,18.45 13.61,19 12,19c-3.87,0 -7,-3.13 -7,-7 0,-1.61 0.55,-3.09 1.46,-4.27l9.81,9.81z" />
</vector>


布局lay_should_location_lay.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/request_location_lay"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/gray_white"
    android:gravity="center"
    android:orientation="vertical"
    android:paddingStart="16dp"
    android:paddingEnd="16dp">
    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:padding="16dp"
        android:src="@drawable/ic_location_off" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="需要位置许可"
        android:textColor="@color/dark_orange"
        android:textSize="16sp" />
    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:layout_marginBottom="16dp"
        android:text="从 Android 6.0 Marshmallow 开始,应用程序需要位置权限才能扫描低功耗蓝牙设备。" />
    <com.google.android.material.button.MaterialButton
        android:id="@+id/btn_request_location_permission"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="授予权限"
        android:textColor="@color/white" />
</LinearLayout>


图标ic_location_off.xml

<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="48dp"
    android:height="48dp"
    android:viewportWidth="24.0"
    android:viewportHeight="24.0">
    <path
        android:fillColor="@color/orange"
        android:pathData="M12,6.5c1.38,0 2.5,1.12 2.5,2.5 0,0.74 -0.33,1.39 -0.83,1.85l3.63,3.63c0.98,-1.86 1.7,-3.8 1.7,-5.48 0,-3.87 -3.13,-7 -7,-7 -1.98,0 -3.76,0.83 -5.04,2.15l3.19,3.19c0.46,-0.52 1.11,-0.84 1.85,-0.84zM16.37,16.1l-4.63,-4.63 -0.11,-0.11L3.27,3 2,4.27l3.18,3.18C5.07,7.95 5,8.47 5,9c0,5.25 7,13 7,13s1.67,-1.85 3.38,-4.35L18.73,21 20,19.73l-3.63,-3.63z" />
</vector>


好了,在我们的努力下这些布局总算是创建完成了,下面我们将它们放置到activity_scan.xml中,代码如下所示:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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="match_parent"
    tools:context=".ScanActivity">
    <com.google.android.material.appbar.MaterialToolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:background="@color/orange"
        app:title="选择蓝牙设备"
        app:titleTextColor="@color/white">
        <TextView
            android:id="@+id/tv_scan_status"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="end"
            android:layout_marginEnd="6dp"
            android:padding="10dp"
            android:text="搜索"
            android:textColor="@color/white"
            android:textSize="14sp"
            android:visibility="gone" />
    </com.google.android.material.appbar.MaterialToolbar>
    <ProgressBar
        android:id="@+id/pb_scan_loading"
        style="?android:attr/progressBarStyleHorizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignBottom="@+id/toolbar"
        android:layout_marginBottom="-10dp"
        android:indeterminate="true"
        android:indeterminateTint="@color/orange"
        android:visibility="invisible" />
    <!--设备列表-->
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_device"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@+id/toolbar"
        android:layout_marginTop="4dp"
        android:overScrollMode="never" />
    <!--未扫描到设备时显示-->
    <include
        android:id="@+id/empty_lay"
        layout="@layout/lay_empty"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@id/toolbar" />
    <!-- Android 12蓝牙扫描权限为许可时显示 Shell_Unresponsive-->
    <include
        android:id="@+id/request_bluetooth_scan_lay"
        layout="@layout/lay_android12_should_scan"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@id/toolbar"
        android:visibility="gone" />
    <!--Android 6 至 11 没有打开位置开关 无法扫描蓝牙-->
    <include
        android:id="@+id/enable_location_lay"
        layout="@layout/lay_should_enable_location"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@id/toolbar"
        android:visibility="gone" />
    <!-- 位置权限未许可时显示 -->
    <include
        android:id="@+id/request_location_lay"
        layout="@layout/lay_should_location_lay"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@id/toolbar"
        android:visibility="gone" />
    <!-- 手机蓝牙未开启时显示 -->
    <include
        android:id="@+id/enable_bluetooth_lay"
        layout="@layout/lay_should_enable_bluetooth"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@id/toolbar"
        android:visibility="gone" />
    <!-- Android12 开启蓝牙需要先请求蓝牙连接权限 -->
    <include
        android:id="@+id/request_bluetooth_connect_lay"
        layout="@layout/lay_android12_should_connect"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@id/toolbar"
        android:visibility="gone" />
</RelativeLayout>


下面我们可以写代码了,在ScanActivity中,


Android Ble蓝牙App(一)扫描(下)https://developer.aliyun.com/article/1407785

相关文章
|
3月前
|
Android开发 开发者 iOS开发
APP开发后如何上架,上架Android应用市场前要准备什么
移动应用程序(APP)的开发已经成为现代企业和开发者的常见实践。然而,开发一个成功的APP只是第一步,将其上架到应用商店让用户下载和使用是实现其潜力的关键一步。
|
6天前
|
测试技术 Android开发
Android App获取不到pkgInfo信息问题原因
Android App获取不到pkgInfo信息问题原因
14 0
|
1月前
|
移动开发 数据安全/隐私保护
HC05蓝牙模块与手机APP连接
HC05蓝牙模块与手机APP连接
42 1
|
1月前
|
设计模式 测试技术 数据库
基于Android的食堂点餐APP的设计与实现(论文+源码)_kaic
基于Android的食堂点餐APP的设计与实现(论文+源码)_kaic
|
2月前
|
安全 Java 数据挖掘
当 App 有了系统权限,真的可以为所欲为? Android Performance Systrace
当 App 有了系统权限,真的可以为所欲为? Android Performance Systrace 转载自: https://androidperformance.com/2023/05/14/bad-android-app-with-system-permissions/#/0-Dex-%E6%96%87%E4%BB%B6%E4%BF%A1%E6%81%AF
31 0
|
3月前
|
Android开发
闲暇时间收集和整理的Android的一些常用的App
闲暇时间收集和整理的Android的一些常用的App
14 0
|
3月前
|
Android开发 UED 开发者
解释Android App Bundle是什么,它的优势是什么?
解释Android App Bundle是什么,它的优势是什么?
59 0
|
3月前
|
传感器 内存技术
毕业设计 江科大STM32的智能温室控制蓝牙声光报警APP系统设计
毕业设计 江科大STM32的智能温室控制蓝牙声光报警APP系统设计
|
3月前
|
JavaScript Android开发
Cordova 后台运行 Android APP
Cordova 后台运行 Android APP