魔改车钥匙实现远程控车:(4)基于compose和经典蓝牙编写一个控制APP

news2024/11/24 23:05:20

前言

这篇文章不出意外的话应该是魔改车钥匙系列的最后一篇了,自此我们的魔改计划除了最后的布线和安装外已经全部完成了。

不过由于布线以及安装不属于编程技术范围,且我也是第一次做,就不献丑继续写一篇文章了。

在前面的文章中,我们已经完成了 Arduino 控制程序的编写,接下来就差编写一个简单易用的手机端控制 APP 了。

这里我们依旧选择使用 compose 作为 UI 框架。

编写这个控制 APP 会涉及到安卓上的蓝牙开发知识,因此我们会先简要介绍一下如何在安卓上进行蓝牙开发。

开始编写

蓝牙基础

蓝牙分为经典蓝牙和低功耗蓝牙(BLE)这个知识点前面的文章已经介绍过了,在我们当前的需求中,我们只需要使用经典蓝牙去与 ESP32 通信,所以我们也只介绍如何使用经典蓝牙。

蓝牙权限

在使用之前,我们需要确保蓝牙权限正确,根据官网教程添加如下权限:

<!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />


<!-- Needed only if your app looks for Bluetooth devices.
     If your app doesn't use Bluetooth scan results to derive physical
     location information, you can strongly assert that your app
     doesn't derive physical location. -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation" />

<!-- Needed only if your app makes the device discoverable to Bluetooth
     devices. -->
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />

<!-- Needed only if your app communicates with already-paired Bluetooth
     devices. -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

实际使用时不用添加所有的权限,只需要根据你的需求添加需要的权限即可。

详细可以查阅官网文档:Bluetooth permissions

因为我们在这里需要连接到 ESP32 所以不要忘记判断运行时权限:

if (ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
	// xxxxx
	// 没有权限
}

某些设备可能不支持经典蓝牙或BLE,亦或是两者均不支持,所以我们需要做一下检查:

private fun PackageManager.missingSystemFeature(name: String): Boolean = !hasSystemFeature(name)
// ...
// Check to see if the Bluetooth classic feature is available.
packageManager.takeIf { it.missingSystemFeature(PackageManager.FEATURE_BLUETOOTH) }?.also {
    Toast.makeText(this, "不支持经典蓝牙", Toast.LENGTH_SHORT).show()
    finish()
}
// Check to see if the BLE feature is available.
packageManager.takeIf { it.missingSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) }?.also {
    Toast.makeText(this, "不支持BLE", Toast.LENGTH_SHORT).show()
    finish()
}

以上代码来自官网示例

初始化蓝牙

在使用蓝牙前,我们需要获取到系统的蓝牙适配器(BluetoothAdapter),后续的大多数操作都将基于这个适配器展开:

val bluetoothManager: BluetoothManager = context.getSystemService(BluetoothManager::class.java)
val bluetoothAdapter = bluetoothManager.adapter

需要注意的是,获取到的 bluetoothAdapter 可能为空,需要自己做一下判空处理。

拿到 bluetoothAdapter 后,下一步是判断是否开启了蓝牙,如果没有开启则需要请求开启:

if (bluetoothAdapter?.isEnabled == false) {
  val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
  startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
}

查找蓝牙设备

由于在这个项目中, ESP32 没法实时加密配对,所以我采用的是直接手动配对好我的手机,然后就不再配对新设备,日后如果有需求,我会研究一下怎么实时加密配对。

所以我们这里暂时不需要搜索新的蓝牙设备,只需要查询已经连接的设备即可:

fun queryPairDevices(): Set<BluetoothDevice>? {
    if (bluetoothAdapter == null) {
        Log.e(TAG, "queryPairDevices: bluetoothAdapter is null!")
        return null
    }
    
    val pairedDevices: Set<BluetoothDevice>? = bluetoothAdapter!!.bondedDevices
    
    pairedDevices?.forEach { device ->
        val deviceName = device.name
        val deviceHardwareAddress = device.address
    
        Log.i(TAG, "queryPairDevices: deveice name=$deviceName, mac=$deviceHardwareAddress")
    }
    
    return pairedDevices
}

连接到指定设备

连接蓝牙设备有两种角色:服务端和客户端,在我们这里的使用场景中,我们的 APP 是客户端,而 ESP32 是服务端,所以我们需要实现的是客户端连接。

因为这里我们连接的是已配对设备,所以相对来说简单的多,不需要做额外的处理,直接连接即可,连接后会拿到一个 BluetoothSocket ,后续的通信将用到这个 BluetoothSocket

 suspend fun connectDevice(device: BluetoothDevice, onConnected : (socket: Result<BluetoothSocket>) -> Unit) {
        val mmSocket: BluetoothSocket? by lazy(LazyThreadSafetyMode.NONE) {
            device.createRfcommSocketToServiceRecord(UUID.fromString("00001101-0000-1000-8000-00805f9b34fb"))
        }

        withContext(Dispatchers.IO) {

            kotlin.runCatching {
                // 开始连接前应该关闭扫描,否则会减慢连接速度
                bluetoothAdapter?.cancelDiscovery()

                mmSocket?.connect()
            }.fold({
                withContext(Dispatchers.Main) {
                    socket = mmSocket
                    onConnected(Result.success(mmSocket!!))
                }
            }, {
                withContext(Dispatchers.Main) {
                    onConnected(Result.failure(it))
                }
                Log.e(TAG, "connectDevice: connect fail!", it)
            })
        }
    }

需要注意的一点是,UUID需要和 ESP32 设置的 UUID 一致,这里我的 ESP32 并没有设置什么特殊的 UUID, 所以我们在 APP 中使用的是常用的 UUID:

Hint: If you are connecting to a Bluetooth serial board then try using the well-known SPP UUID 00001101-0000-1000-8000-00805F9B34FB. However if you are connecting to an Android peer then please generate your own unique UUID.

另外,其实从名字 Socket 就能看出,这是个耗时操作,所以我们将其放到协程中,并使用工作线程执行 withContext(Dispatchers.IO)

对了,上面的代码中,我加了一句 bluetoothAdapter?.cancelDiscovery() 其实这行代码在这里纯属多余,因为我压根没有搜索设备的操作,但是为了避免我以后新增搜索设备后忘记加上,所以我没有给它删掉。

最后,我这里使用了一个匿名函数回调连接结果 onConnected : (socket: Result<BluetoothSocket>) -> Unit

数据通信

数据通信需要使用上一节拿到的 BluetoothSocket ,通过 read BluetoothSocketInputStream 从服务端读取数据;write BluetoothSocketOutputStream 往服务端写入数据:

suspend fun startBtReceiveServer(mmSocket: BluetoothSocket, onReceive: (numBytes: Int, byteBufferArray: ByteArray) -> Unit) {
    keepReceive = true
    val mmInStream: InputStream = mmSocket.inputStream
    val mmBuffer = ByteArray(1024) // 缓冲区大小

    withContext(Dispatchers.IO) {
        var numBytes = 0 // 实际读取的数据大小
        while (true) {

            kotlin.runCatching {
                mmInStream.read(mmBuffer)
            }.fold(
                {
                    numBytes = it
                },
                {
                    Log.e(TAG, "Input stream was disconnected", it)
                    return@withContext
                }
            )

            withContext(Dispatchers.Main) {
                onReceive(numBytes, mmBuffer)
            }
        }
    }
}

suspend fun sendByteToDevice(mmSocket: BluetoothSocket, bytes: ByteArray, onSend: (result: Result<ByteArray>) -> Unit) {
    val mmOutStream: OutputStream = mmSocket.outputStream

    withContext(Dispatchers.IO) {
        val result = kotlin.runCatching {
            mmOutStream.write(bytes)
        }

        if (result.isFailure) {
            Log.e(TAG, "Error occurred when sending data", result.exceptionOrNull())
            onSend(Result.failure(result.exceptionOrNull() ?: Exception("not found exception")))
        }
        else {
            onSend(Result.success(bytes))
        }
    }
}

同样的,这里的读取和写入都是耗时操作,所以我都声明了是挂起函数 suspend

另外,接收服务器的数据时,需要一直循环读取 inputStream 直至 socket 抛出异常(连接被断开)。

这里我们在接收到新数据时,依然使用一个匿名函数回调接收到的数据 onReceive: (numBytes: Int, byteBufferArray: ByteArray) -> Unit

其中 numBytes 是本次接收到的数据大小, byteBufferArray 是完整的缓冲数组,实际数据可能没有这么多。

完整的帮助类

结合我们的需求,我写了一个蓝牙连接和通信的帮助类 BtHelper :

class BtHelper {
    private var bluetoothAdapter: BluetoothAdapter? = null
    private var keepReceive: Boolean = true

    companion object {
        private const val TAG = "BtHelper"

        val instance by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
            BtHelper()
        }
    }

    fun init(bluetoothAdapter: BluetoothAdapter) {
        this.bluetoothAdapter = bluetoothAdapter
    }

    fun init(context: Context): Boolean {
        val bluetoothManager: BluetoothManager = context.getSystemService(BluetoothManager::class.java)
        this.bluetoothAdapter = bluetoothManager.adapter
        return if (bluetoothAdapter == null) {
            Log.e(TAG, "init: bluetoothAdapter is null, may this device not support bluetooth!")
            false
        } else {
            true
        }
    }

    fun checkBluetooth(context: Context): Boolean {
        return ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
                && bluetoothAdapter?.isEnabled == true
    }

    @SuppressLint("MissingPermission")
    fun queryPairDevices(): Set<BluetoothDevice>? {
        if (bluetoothAdapter == null) {
            Log.e(TAG, "queryPairDevices: bluetoothAdapter is null!")
            return null
        }

        val pairedDevices: Set<BluetoothDevice>? = bluetoothAdapter!!.bondedDevices

        pairedDevices?.forEach { device ->
            val deviceName = device.name
            val deviceHardwareAddress = device.address

            Log.i(TAG, "queryPairDevices: deveice name=$deviceName, mac=$deviceHardwareAddress")
        }

        return pairedDevices
    }

    @SuppressLint("MissingPermission")
    suspend fun connectDevice(device: BluetoothDevice, onConnected : (socket: Result<BluetoothSocket>) -> Unit) {
        val mmSocket: BluetoothSocket? by lazy(LazyThreadSafetyMode.NONE) {
            device.createRfcommSocketToServiceRecord(UUID.fromString("00001101-0000-1000-8000-00805f9b34fb"))
        }

        withContext(Dispatchers.IO) {

            kotlin.runCatching {
                // 开始连接前应该关闭扫描,否则会减慢连接速度
                bluetoothAdapter?.cancelDiscovery()

                mmSocket?.connect()
            }.fold({
                withContext(Dispatchers.Main) {
                    onConnected(Result.success(mmSocket!!))
                }
            }, {
                withContext(Dispatchers.Main) {
                    onConnected(Result.failure(it))
                }
                Log.e(TAG, "connectDevice: connect fail!", it)
            })
        }
    }

    fun cancelConnect(mmSocket: BluetoothSocket?) {
        try {
            mmSocket?.close()
        } catch (e: IOException) {
            Log.e(TAG, "Could not close the client socket", e)
        }
    }

    suspend fun startBtReceiveServer(mmSocket: BluetoothSocket, onReceive: (numBytes: Int, byteBufferArray: ByteArray) -> Unit) {
        keepReceive = true
        val mmInStream: InputStream = mmSocket.inputStream
        val mmBuffer = ByteArray(1024) // mmBuffer store for the stream

        withContext(Dispatchers.IO) {
            var numBytes = 0 // bytes returned from read()
            while (true) {

                kotlin.runCatching {
                    mmInStream.read(mmBuffer)
                }.fold(
                    {
                        numBytes = it
                    },
                    {
                        Log.e(TAG, "Input stream was disconnected", it)
                        return@withContext
                    }
                )

                withContext(Dispatchers.Main) {
                    onReceive(numBytes, mmBuffer)
                }
            }
        }
    }

    fun stopBtReceiveServer() {
        keepReceive = false
    }

    suspend fun sendByteToDevice(mmSocket: BluetoothSocket, bytes: ByteArray, onSend: (result: Result<ByteArray>) -> Unit) {
        val mmOutStream: OutputStream = mmSocket.outputStream

        withContext(Dispatchers.IO) {
            val result = kotlin.runCatching {
                mmOutStream.write(bytes)
            }

            if (result.isFailure) {
                Log.e(TAG, "Error occurred when sending data", result.exceptionOrNull())
                onSend(Result.failure(result.exceptionOrNull() ?: Exception("not found exception")))
            }
            else {
                onSend(Result.success(bytes))
            }
        }
    }
}

通信协议与需求

在上一篇文章写完之后,其实我又加了许多功能。

但是我们的需求实际上总结来说就两个:

  1. 能够直接在手机 APP 上模拟触发遥控器按键
  2. 能够设置 ESP32 的某些参数

结合这个需求,我们制定了如下通信协议(这里只写了重要的):

单指令:

指令功能说明
1开启电源给遥控器供电
2关闭电源断开遥控器供电
8读取当前主板状态(友好文本)读取当前主板的状态信息,以友好文本形式返回
9读取主板设置参数(格式化文本)读取当前主板保存的设置参数,以格式化文本返回
101触发上锁按键
102断开上锁按键
103触发解锁按键
104断开解锁按键
105触发多功能按键
106断开多功能按键

设置参数指令:

设置参数内容格式依旧如同上篇文章所述,这里并没有做更改。

参数码功能说明
1设置间隔时间设置 BLE 扫描一次的时间
2设置 RSSI 阈值设置识别 RSSI 的阈值
3设置是否触发解锁按键设置扫描到手环且RSSI阈值符合后,是否触发解锁按键,不开启该项则只会给遥控起上电,不会自动解锁
4设置是否启用感应解锁设置是否启用感应解锁,不开启则不会扫描手环,只能手动连接主板并给遥控器上电解锁
5设置扫描失败多少次后触发上锁设置扫描设备失败多少次后才会触发上锁并断电,有时扫描蓝牙会间歇性的扫描失败,增加该选项是为了避免正常使用时被错误的上锁

编写 APP

界面设计

由于本文的重点不在于如何设计界面,所以这里不再赘述怎么实现界面,我直接就上最终实现效果即可。

对了,由于现在还在测试,所以最终界面肯定不会这么简陋的(也许吧)。

主页(等待连接):

s1.jpg

控制页:

s2.jpg

控制页(打开设置):

s3.jpg

逻辑实现

其实,这个代码逻辑也很简单,这里就挑几个说说,其他的大伙可以直接看源码。

何时初始化

上面说到,我为蓝牙通信编写了一个简单的帮助类,并且实现了一个单例模式:

companion object {

    val instance by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
        BtHelper()
    }
}

我最开始是在 Application 中调用 BtHelper.instance.init(this) 初始化,但是我后来发现,这样初始化的话,在实际使用中时,bluetoothAdapter 始终为 null 。

没办法,我把初始化放到了顶级 composable 中:

@Composable
fun HomeView(viewModel: HomeViewModel) {
    val context = LocalContext.current
    DisposableEffect(Unit) {
        viewModel.dispatch(HomeAction.InitBt(context))

        onDispose { }
    }
    // .....
}

InitBt 这个 Action 中,我调用了 BtHelper.instance.init(context) 重新初始化。

这下基本没问题了。

发送模拟按键数据

因为遥控器的按键涉及到短按和长按的逻辑操作,所以这里我不能直接使用 Button 的点击回调,而是要自己处理按下和抬起手指事件。

并且在按下 Button 时发送触发按键指令,松开 Button 时触发断开按键命令。

以上锁这个 Button 为例:

Button(
    onClick = { },
    modifier = Modifier.presBtn {
        viewModel.dispatch(HomeAction.OnClickButton(ButtonIndex.Lock, it))
    }
) {
    Text(text = "上锁")
}

其中,presBtn 是我自己定义的一个扩展函数:

@SuppressLint("UnnecessaryComposedModifier")
@OptIn(ExperimentalComposeUiApi::class)
inline fun Modifier.presBtn(crossinline onPress: (btnAction: ButtonAction)->Unit): Modifier = composed {

    pointerInteropFilter {
        when (it.action) {
            MotionEvent.ACTION_DOWN -> {
                onPress(ButtonAction.Down)
            }
            MotionEvent.ACTION_UP -> {
                onPress(ButtonAction.Up)
            }
        }
        true
    }

}

我在这个扩展函数中通过 pointerInteropFilter 获取原始触摸事件,并回调其中的 ACTION_DOWNACTION_UP 事件。

然后在 OnClickButton 这个 Action 中做如下处理:

private fun onClickButton(index: ButtonIndex, action: ButtonAction) {
    val sendValue: Byte = when (index) {
        ButtonIndex.Lock -> {
            if (action == ButtonAction.Down) 101
            else 102
        }
    }

    viewModelScope.launch {
        BtHelper.instance.sendByteToDevice(socket!!, byteArrayOf(sendValue)) {
            it.fold(
                {
                    Log.i(TAG, "seed successful: byte= ${it.toHexStr()}")
                },
                {
                    Log.e(TAG, "seed fail", it)
                }
            )
        }
    }
}

为了避免读者看起来太混乱,这里删除了其他按键的判断,只保留了上锁按键。

通过判断是 按下事件 还是 抬起事件 来决定发送给 ESP32 的指令是 101 还是 102

读取数据

在这个项目中,我们涉及到读取数据的地方其实就两个:读取状态(友好文本和格式化文本)。

其中返回的数据格式,在上面界面设计一节中的最后两张截图已经有所体现,上面返回的是友好文本,下面是格式化文本。

其中格式化文本我需要解析出来并更新到 UI 上(设置界面):

BtHelper.instance.startBtReceiveServer(socket!!, onReceive = { numBytes, byteBufferArray ->
    if (numBytes > 0) {
        val contentArray = byteBufferArray.sliceArray(0..numBytes)
        val contentText = contentArray.toText()

        Log.i(TAG, "connectDevice: rev:numBytes=$numBytes, " +
                "\nbyteBuffer(hex)=${contentArray.toHexStr()}, " +
                "\nbyteBuffer(ascii)=$contentText"
        )

        viewStates = viewStates.copy(logText = "${viewStates.logText}\n$contentText")

        if (contentText.length > 6 && contentText.slice(0..2) == "Set") {
            Log.i(TAG, "connectDevice: READ from setting")
            val setList = contentText.split(",")
            viewStates = viewStates.copy(
                availableInduction = setList[1] != "0",
                triggerUnlock = setList[2] != "0",
                scanningTime = setList[3],
                rssiThreshold = setList[4],
                shutdownThreshold = setList[5],
                isReadSettingState = false
            )
        }
    }
})

对了,我还写了一个转换类(FormatUtils),用于处理返回数据:

object FormatUtils {

    /**
     * 将十六进制字符串转成 ByteArray
     * */
    fun hexStrToBytes(hexString: String): ByteArray {
        check(hexString.length % 2 == 0) { return ByteArray(0) }

        return hexString.chunked(2)
            .map { it.toInt(16).toByte() }
            .toByteArray()
    }

    /**
     * 将十六进制字符串转成 ByteArray
     * */
    fun String.toBytes(): ByteArray {
        return hexStrToBytes(this)
    }

    /**
     * 将 ByteArray 转成 十六进制字符串
     * */
    fun bytesToHexStr(byteArray: ByteArray) =
        with(StringBuilder()) {
            byteArray.forEach {
                val hex = it.toInt() and (0xFF)
                val hexStr = Integer.toHexString(hex)
                if (hexStr.length == 1) append("0").append(hexStr)
                else append(hexStr)
            }
            toString().uppercase(Locale.CHINA)
        }

    /**
     * 将字节数组转成十六进制字符串
     * */
    fun ByteArray.toHexStr(): String {
        return bytesToHexStr(this)
    }

    /**
     * 将字节数组解析成文本(ASCII)
     * */
    fun ByteArray.toText(): String {
        return String(this)
    }

    /**
     * 将 ByteArray 转为 bit 字符串
     * */
    fun ByteArray.toBitsStr(): String {
        if (this.isEmpty()) return ""
        val sb = java.lang.StringBuilder()
        for (aByte in this) {
            for (j in 7 downTo 0) {
                sb.append(if (aByte.toInt() shr j and 0x01 == 0) '0' else '1')
            }
        }
        return sb.toString()
    }

    /**
     *
     * 将十六进制字符串转成 ASCII 文本
     *
     * */
    fun String.toText(): String {
        val output = java.lang.StringBuilder()
        var i = 0
        while (i < this.length) {
            val str = this.substring(i, i + 2)
            output.append(str.toInt(16).toChar())
            i += 2
        }
        return output.toString()
    }

    /**
     * 将十六进制字符串转为带符号的 Int
     * */
    fun String.toNumber(): Int {
        return this.toInt(16).toShort().toInt()
    }

    /**
     * 将整数转成有符号十六进制字符串
     *
     * @param length 返回的十六进制总长度,不足会在前面补 0 ,超出会将前面多余的去除
     * */
    fun Int.toHex(length: Int = 4): String {
        val hex = Integer.toHexString(this).uppercase(Locale.CHINA)
        return hex.padStart(length, '0').drop((hex.length-length).coerceAtLeast(0))
    }

}

项目地址

auto_controller

欢迎 star!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/549097.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

基于torch实现模型剪枝

一、剪枝分类 所谓模型剪枝&#xff0c;其实是一种从神经网络中移除"不必要"权重或偏差&#xff08;weigths/bias&#xff09;的模型压缩技术。关于什么参数才是“不必要的”&#xff0c;这是一个目前依然在研究的领域。 1.1、非结构化剪枝 非结构化剪枝&#xff08;…

什么是可持续能源?

随着全球经济的不断发展和人口的不断增长&#xff0c;能源问题越来越受到关注。传统能源已经不能满足人们对能源的需求&#xff0c;同时也对环境和健康带来了严重的影响。为了解决这些问题&#xff0c;出现了可持续能源的概念。那么&#xff0c;什么是可持续能源呢&#xff1f;…

逐渐从土里长出来的小花

从土里逐渐长出来的小花&#xff08;这是长出来后的样子&#xff0c;图片压缩了出现了重影~&#xff09; 代码在这里&#xff1a; <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><title>Title</title&g…

MySQL-索引(2)

本文主要讲解MySQL-索引相关的知识点 联合索引前缀索引覆盖索引索引下推索引的优缺点什么时候适合创建索引,什么时候不适合?如何优化索引 ? 索引失效场景 ? 为什么SQL语句使用了索引,却还是慢查询 ? 使用索引有哪些注意事项 ? InnoDB引擎中的索引策略 目录 联合索引 联合…

LeetCode高频算法刷题记录6

文章目录 1. 编辑距离【困难】1.1 题目描述1.2 解题思路1.3 代码实现 2. 寻找两个正序数组的中位数【困难】2.1 题目描述2.2 解题思路2.3 代码实现 3. 合并区间【中等】3.1 题目描述3.2 解题思路3.3 代码实现 4. 爬楼梯【简单】4.1 题目描述4.2 解题思路4.3 代码实现 5. 排序链…

chatgpt赋能Python-python3_9安装numpy

Python 3.9 安装 NumPy 的完整指南 Python是一种功能强大的编程语言&#xff0c;已成为数据分析、人工智能和科学计算领域的主流语言之一。NumPy是一个Python库&#xff0c;用于执行高效的数值计算和科学计算操作。Python 3.9是Python最新版本&#xff0c;带来了许多新功能和改…

一款非常有趣的中国版本的Excalidraw作图工具drawon(桌案)

桌案工具集成了很多有趣的在线作图工具&#xff0c; 思维导图&#xff0c; 流程图&#xff0c;以及草图&#xff0c;在线ppt等功能。 而草图是基于国外有名的Excalidraw而改造而来&#xff0c;使得它更符合国人的使用习惯。 最近在 使用excalidraw时&#xff0c;发现了很多新功…

Excel | 基因名都被Excel篡改了怎么办呢!?~(附3种解决方案)

1写在前面 今天和大家分享一下在做表达矩阵处理时尝尝会遇到的一个问题&#xff0c;但又经常被忽视&#xff0c;就是Excel会修改你的基因名。&#x1f637; 无数大佬在这里都踩过坑&#xff0c;这些普遍的问题已经被写成了paper&#xff08;左右滑动&#xff09;&#xff1a;&a…

75.建立一个主体样式第一部分

我们的目标如下图所示 ● 首先建立文件夹&#xff0c;生成框架代码 ● 把页面上面的HTML元素写进去 <header><nav><div>LOGO</div><div>NAVIGATION</div></nav><div><h1>A healty meal delivered to your door, ever…

Java基础--->并发部分(2)【Java中的锁】

文章目录 synchronized和ReentrantLock的区别Java中锁的名词synchronized锁ReentrantLock锁 synchronized和ReentrantLock的区别 synchronized 和 ReentrantLock 都可以用来实现 Java 中的线程同步。它们的作用类似&#xff0c;但是在用法和特性上还是有一些区别的。 synchroni…

【2023/05/20】Visual Basic

Hello&#xff01;大家好&#xff0c;我是霜淮子&#xff0c;2023倒计时第15天。 Visual Basic是一种广泛应用于Windows操作系统的编程语言&#xff0c;它是Microsoft公司开发的一种面向对象的编程语言&#xff0c;以其简单、易学、易用的特点受到广泛欢迎。本文旨在介绍Visual…

2023年申请美国大学,需要SAT/ACT成绩吗?

受疫情影响&#xff0c;2021 和 2022 年申请美国大学时&#xff0c;许多大学都放宽了SAT/ACT门槛&#xff0c;不强行要求学生提交标化成绩。今年3月&#xff0c;理工大牛院校 MIT 率先打破了这个局面&#xff0c;宣布恢复 SAT/ACT 标化成绩要求&#xff0c;随后几个大学也陆续宣…

Java --- 云尚办公用户管理模块实现

目录 一、用户管理 1.1、数据库表 1.2、使用代码生成器生成相关代码 1.3、后端代码 1.4、前端代码 二、用户与角色功能实现 一、用户管理 1.1、数据库表 CREATE TABLE sys_user (id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 会员id,username VARCHAR(20) NOT NU…

Java面试知识点(全)-spring面试知识点一

Java面试知识点(全) 导航&#xff1a; https://nanxiang.blog.csdn.net/article/details/130640392 注&#xff1a;随时更新 Spring原理 Spring ioc概念&#xff1a;控制权由对象本身转向容器&#xff1b;由容器根据配置文件去创建实例并创建各个实例之间的依赖关系。核心&am…

学术会议参会经验分享一(参会前的准备工作)

前前后后参加了两次学术会议&#xff0c;一次是今年三月份在深圳&#xff0c;另一次是在五月份在南宁&#xff0c;并且两次都进行了主题演讲。总的来说&#xff0c;我感觉参加学术会议重要的是自身能力的提升&#xff0c;比如说演讲、PPT制作等更方面的能力。下面我来分享一些我…

USRP概念基础

GBIC Gigabit Interface Converter的缩写,是将千兆位电信号转换为光信号的接口器件。GBIC设计上可以为热插拔使用。 SFP SFP (Small Form Pluggable)可以简单理解为GBIC(Gigabit Interface Converter的缩写)升级版本,是将千兆位电信号转换为光信号的接口器件,可以热插…

python字符串拼接

首先 什么是字符串拼接 我们来看一个段代码 print("你好""小猫猫")运行结果如下 这是一个最简单的演示 字符串 与 字符串的拼接 两个字符串字面量可以直接用加号 合并成一个字符串 当然 直接这里 字面量字面量 直接写上去看着会非常傻 所以 一般都是 变…

( 动态规划) 516. 最长回文子序列 ——【Leetcode每日一题】

❓516. 最长回文子序列 难度&#xff1a;中等 给你一个字符串 s &#xff0c;找出其中最长的回文子序列&#xff0c;并返回该序列的长度。 子序列定义为&#xff1a;不改变剩余字符顺序的情况下&#xff0c;删除某些字符或者不删除任何字符形成的一个序列。 示例 1&#xf…

MarkDown语法2

MarkDown语法2 一、基本语法 1. 标题 一级标题&#xff1a;# 一级标题二级标题&#xff1a;## 二级标题 2. 字体 斜体&#xff1a;*斜体*,_斜体_粗体&#xff1a;**粗体**&#xff0c;__粗体__粗斜体&#xff1a;***粗斜体***, ___粗斜体___ 3. 线 分割线&#xff1a;&a…

java常用工具之Objects类

目录 简介一、对象判空二、 对象为空时抛异常三、 判断两个对象是否相等四、 获取对象的hashcode五、 比较两个对象六、比较两个数组七、 小结 简介 Java 的 Objects 类是一个实用工具类&#xff0c;包含了一系列静态方法&#xff0c;用于处理对象。它位于 java.util 包中&…