news 2026/10/16 23:01:36

Android蓝牙开发避坑指南:从权限适配到稳定通信全链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android蓝牙开发避坑指南:从权限适配到稳定通信全链路

简介:本资源是一个面向Android开发初学者与中级工程师的蓝牙通信实战Demo,聚焦经典蓝牙(SPP)场景下的设备发现、配对连接、消息收发等核心功能实现,有效解决移动端短距离无线通信的入门实践难题。压缩包共1544个文件,24.82MB,包含552个XML布局与配置文件、498个PNG图标资源、162个编译后class字节码、133个JSON配置及元数据、11个Java源文件(含BluetoothAdapter调用、BroadcastReceiver监听、BluetoothSocket连接与IO流处理等关键逻辑),以及APK安装包和Gradle构建脚本,结构完整,可直接导入Android Studio运行调试。已有869人学习下载,代码组织清晰,覆盖权限声明、异常捕获、UI状态反馈与基础安全校验等工程化细节,是理解Android蓝牙API体系与快速搭建通信原型的优质参考模板。

1. Android 蓝牙通讯 Demo:不是连上就行,而是让设备在 3 米内稳定收发 200 字节/秒、不丢包、不重连的最小可验证闭环

你写了个BluetoothAdapter.enable(),Logcat 显示“enabled”,就以为蓝牙通了?错。真实产线里,某智能工装模块在产线传送带旁部署后,每 7 分钟自动断连一次;某医疗手环 Demo 在测试机上跑得飞起,换三台同型号新机就卡在fetchUuidsWithSdp()死循环;还有开发者把createRfcommSocketToServiceRecord()放在主线程——UI 冻结 12 秒,用户直接划掉应用。这不是玄学,是 Android 蓝牙栈从 API 10 到 API 34 的权限演进、协议栈分层、状态机跳转、线程模型和底层 HCI 缓冲区共同作用的结果。这个 Demo 不是教你怎么点开系统设置配对,而是带你从零构建一个能扛住弱信号、支持重连退避、区分经典蓝牙(BR/EDR)与低功耗蓝牙(BLE)双模式、且所有通信逻辑可打断、可日志追踪、可单元测试的通讯基座。适合正在做蓝牙外设对接(如体征采集仪、工业传感器、POS 扫码枪)、需要嵌入式侧联调、或正被「配对成功但发不出数据」问题卡住三天的 Android 开发者。它不依赖任何第三方 SDK,只用原生android.bluetooth包,代码量控制在 400 行以内,但覆盖了从发现设备、建立连接、读写数据到异常恢复的全链路。

2. 从权限声明到设备发现:为什么你的 App 在 Android 12+ 上根本搜不到隔壁手机?

2.1 权限清单必须按 Android 版本阶梯式声明:漏掉任意一层,发现即失败

Android 对蓝牙扫描的管控逐年收紧。API 31(Android 12)起,BLUETOOTH_ADMIN已废弃;API 33(Android 13)起,后台扫描需额外BLUETOOTH_SCAN运行时权限;而从 API 34(Android 14)开始,即使前台扫描也强制要求NEARBY_DEVICES权限,且该权限属于dangerous组,必须动态申请。更关键的是:ACCESS_FINE_LOCATION不再是“可选”,而是BLUETOOTH_SCAN的硬性前置条件——因为蓝牙地址(BD_ADDR)在物理层可被用于粗略定位。

<!-- AndroidManifest.xml --> <uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <!-- 以下为 API 31+ 必须 --> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <!-- API 33+ 后台扫描需此权限 --> <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" /> <!-- 定位权限:非可选,是蓝牙扫描的法定前提 --> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <!-- API 34+ 新增:替代旧权限组 --> <uses-permission android:name="android.permission.NEARBY_DEVICES" />

提示:NEARBY_DEVICES权限在targetSdkVersion >= 34时不可降级回BLUETOOTH_SCAN。若你仍用BLUETOOTH_SCAN,系统会静默拒绝扫描请求,Logcat 仅输出模糊的Scan failed: reason=2(即SCAN_FAILED_INTERNAL_ERROR),不会提示缺权限。

2.2 动态权限申请必须分两步走:先位置,再蓝牙,且顺序不可逆

很多翻车源于在onRequestPermissionsResult里一把梭哈所有权限。正确做法是:先确保ACCESS_FINE_LOCATION已获准,再申请NEARBY_DEVICES。因为系统会校验位置服务是否开启——若用户仅授予权限但关闭 GPS/Wi-Fi 定位,NEARBY_DEVICES申请会直接返回PERMISSION_DENIED。

// Kotlin 示例:分步权限申请 private fun requestLocationThenBluetooth() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), REQUEST_CODE_LOCATION ) } else { requestNearbyDevicesPermission() // 位置已就绪,再申蓝牙 } } private fun requestNearbyDevicesPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // API 34+ ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.NEARBY_DEVICES), REQUEST_CODE_NEARBY ) } else { // API 31-33 ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT), REQUEST_CODE_BLUETOOTH ) } }

逻辑说明:REQUEST_CODE_LOCATION和REQUEST_CODE_NEARBY必须不同,否则onRequestPermissionsResult无法区分回调来源。参数说明:Manifest.permission.NEARBY_DEVICES是 Android 14 引入的聚合权限,覆盖扫描、连接、广播三类操作,不再需要单独申请BLUETOOTH_SCAN等子权限。

2.3 设备发现必须用BluetoothLeScanner+ScanCallback:startDiscovery()已成历史

BluetoothAdapter.startDiscovery()在 API 31 后被标记为@Deprecated,且在 Android 12+ 上实际调用会抛SecurityException。真实可用的发现方式只有两种:

  • 经典蓝牙(BR/EDR)设备:用fetchUuidsWithSdp()主动探测已配对设备的服务 UUID(适用于已知目标 MAC 地址);
  • BLE 设备(主流):必须用BluetoothLeScanner启动异步扫描,通过ScanCallback接收ScanResult。
private fun startBleScan() { val scanner = bluetoothManager.adapter.bluetoothLeScanner val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // 高频扫描,适合调试 .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) // 提高匹配灵敏度 .setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT) // 最大匹配数 .build() val filter = ScanFilter.Builder() .setDeviceAddress("AA:BB:CC:DD:EE:FF") // 可选:精确过滤目标设备 .build() scanner.startScan(listOf(filter), settings, scanCallback) } private val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { Log.d("BLE", "Found: ${result.device.address} RSSI: ${result.rssi}") // 此处可触发连接逻辑 connectToDevice(result.device) } override fun onBatchScanResults(results: List<ScanResult>) { // 批量结果回调,省电模式下触发 } override fun onScanFailed(errorCode: Int) { Log.e("BLE", "Scan failed: $errorCode") // errorCode=1: SCAN_FAILED_ALREADY_STARTED // errorCode=2: SCAN_FAILED_APPLICATION_REGISTRATION_FAILED // errorCode=3: SCAN_FAILED_INTERNAL_ERROR } }

参数说明:SCAN_MODE_LOW_LATENCY每 100ms 扫描一次,耗电高但响应快,Demo 阶段必用;MATCH_MODE_AGGRESSIVE降低匹配阈值,避免因信号抖动漏掉设备;setDeviceAddress()是硬过滤,生产环境慎用——BLE 设备地址可能随机化,应优先用ScanFilter.Builder().setServiceUuid()匹配服务 UUID。

3. 建立可靠连接:为什么createRfcommSocketToServiceRecord()总是 timeout?

3.1 经典蓝牙(SPP)连接必须绕过createRfcommSocketToServiceRecord()的坑

BluetoothDevice.createRfcommSocketToServiceRecord(UUID)是最常被误用的 API。它内部会发起 SDP 查询,而 Android 系统对 SDP 的超时控制极不友好:默认 12 秒无响应即抛IOException,且该超时不可配置。更糟的是,某些国产 ROM(如某实验室测试用的定制 Android 11)会静默拦截 SDP 请求,导致永远卡在connect()。

正确解法:硬编码 RFCOMM 通道号,用createInsecureRfcommSocketToServiceRecord()绕过 SDP。前提是:你已知目标设备的 SPP 通道号(通常为 1,可通过sdptool browse在 Linux 下查,或设备文档确认)。

private fun createInsecureSocket(device: BluetoothDevice): BluetoothSocket? { return try { // 硬编码通道号 1,跳过 SDP 查询 val method = device.javaClass.getMethod( "createInsecureRfcommSocket", Integer.TYPE ) method.invoke(device, 1) as BluetoothSocket } catch (e: Exception) { Log.e("SPP", "Create insecure socket failed", e) null } } // 连接时启用超时保护 private fun connectWithTimeout(socket: BluetoothSocket, timeoutMs: Int = 10_000) { val thread = Thread { try { socket.connect() // 此处阻塞 } catch (e: IOException) { Log.e("SPP", "Connect failed", e) } } thread.start() thread.join(timeoutMs) // 主线程等待最多 timeoutMs if (thread.isAlive) { Log.w("SPP", "Connect timeout, closing socket") socket.close() } }

逻辑说明:createInsecureRfcommSocketToServiceRecord()是隐藏 API,需用反射调用;thread.join(timeoutMs)是唯一可控的连接超时方案——socket.connect()本身不支持传入 timeout 参数。参数说明:timeoutMs=10_000是经验值,低于 8000ms 易受系统调度影响误判,高于 15000ms 用户体验差。

3.2 BLE 连接必须用BluetoothGatt状态机:别在onConnectionStateChange里直接读特征值

BLE 连接是典型的异步状态机。常见错误是在onConnectionStateChange回调中立即调用gatt.readCharacteristic(),此时 GATT 服务尚未发现(onServicesDiscovered未触发),必然返回false。

private var gatt: BluetoothGatt? = null private fun connectToBleDevice(device: BluetoothDevice) { gatt = device.connectGatt(this, false, gattCallback) } private val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED) { Log.d("BLE", "Connected, discovering services...") gatt.discoverServices() // 必须显式调用! } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { Log.d("BLE", "Disconnected") } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { Log.d("BLE", "Services discovered") // 此时才能安全获取 characteristic val service = gatt.getService(UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")) val characteristic = service?.getCharacteristic( UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb") ) if (characteristic != null) { gatt.readCharacteristic(characteristic) // 现在可以读了 } } } override fun onCharacteristicRead( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int ) { if (status == BluetoothGatt.GATT_SUCCESS) { Log.d("BLE", "Read success: ${characteristic.value.contentToString()}") } } }

逻辑说明:discoverServices()是 GATT 通信的基石,它向设备发起 ATT 协议请求,获取服务、特征、描述符的完整拓扑。参数说明:两个 UUID 示例为常见 BLE 串口透传服务(Nordic UART Service),实际使用需替换为目标设备的官方 UUID。

4. 数据收发与粘包处理:为什么你收到的字节流总是错位、截断、或混在一起?

4.1 经典蓝牙(SPP)必须用InputStream+BufferedInputStream防粘包

SPP 基于 RFCOMM,本质是流式协议,没有消息边界。InputStream.read()可能一次只读到 1 字节,也可能一次读到 200 字节,全看底层 HCI 缓冲区和系统调度。直接read(buffer)会导致数据错位。

private fun startSppDataLoop(socket: BluetoothSocket) { val input = BufferedInputStream(socket.inputStream, 1024) // 缓冲区设为 1KB val buffer = ByteArray(1024) Thread { while (isConnected && !Thread.currentThread().isInterrupted) { try { val len = input.read(buffer) // 阻塞读取 if (len > 0) { val data = buffer.copyOfRange(0, len) handleSppData(data) // 交由业务逻辑处理 } } catch (e: IOException) { Log.e("SPP", "Read error", e) break } } }.start() } private fun handleSppData(data: ByteArray) { // 方案一:按固定帧头帧尾解析(推荐 Demo 使用) // 假设帧格式:0xAA + LEN(1B) + PAYLOAD(LEN B) + 0x55 var i = 0 while (i < data.size) { if (data[i] == 0xAA.toByte()) { if (i + 2 < data.size) { val len = data[i + 1].toInt() and 0xFF if (i + 2 + len + 1 < data.size && data[i + 2 + len] == 0x55.toByte()) { val payload = data.copyOfRange(i + 2, i + 2 + len) Log.d("SPP", "Frame received: ${payload.contentToString()}") i += 2 + len + 1 // 跳过整帧 continue } } } i++ } }

逻辑说明:BufferedInputStream减少系统调用次数,提升吞吐;handleSppData()实现简易帧解析,避免粘包。参数说明:0xAA/0x55是自定义帧头帧尾,生产环境建议用 CRC 校验;len字段确保能准确切分 payload,防止跨包解析。

4.2 BLE 特征值读写必须用BluetoothGattCharacteristic的WRITE_TYPE_NO_RESPONSE模式保实时性

BLE 写入默认是WRITE_TYPE_DEFAULT,即写后必须等设备返回onCharacteristicWrite()确认。这对传感器上报等低延迟场景是灾难——单次往返至少 50ms,吞吐上限约 20Hz。改用WRITE_TYPE_NO_RESPONSE可将写入频率推至 100Hz+,但需承担丢包风险。

private fun writeBleData(gatt: BluetoothGatt, data: ByteArray) { val characteristic = gatt.getService(SERVICE_UUID)?.getCharacteristic(CHAR_UUID) characteristic?.let { it.value = data it.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE // 关键! gatt.writeCharacteristic(it) } } // 读取时注意:onCharacteristicChanged() 是通知回调,需先 enable notification private fun enableNotification(gatt: BluetoothGatt) { val characteristic = gatt.getService(SERVICE_UUID)?.getCharacteristic(CHAR_UUID) characteristic?.let { gatt.setCharacteristicNotification(it, true) val descriptor = it.getDescriptor( UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") // CCCD ) descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor) } }

参数说明:WRITE_TYPE_NO_RESPONSE仅适用于“发了就不管”的场景(如实时姿态数据);ENABLE_NOTIFICATION_VALUE是标准 CCCD(Client Characteristic Configuration Descriptor)值,用于开启服务端主动推送。

5. 避坑:那些让你加班到凌晨三点的 BLE/SPP 连接黑匣子

5.1 现象:BluetoothAdapter.isEnabled()返回 true,但getBondedDevices()为空,且startDiscovery()无反应

原因:Android 系统在某些 ROM(尤其厂商深度定制版)中,isEnabled()仅表示蓝牙射频已开启,但协议栈(Bluedroid 或 Fluoride)可能未加载完成。此时getBondedDevices()和startDiscovery()会静默失败。
解决:增加协议栈就绪检测——轮询BluetoothAdapter.getState(),等待其从STATE_TURNING_ON变为STATE_ON后再执行后续操作。实测某国产平板需等待 1.8 秒。

5.2 现象:BLE 连接后onServicesDiscovered()不触发,Logcat 显示D/BluetoothGatt: onSearchComplete() = 0

原因:目标设备未实现完整的 GATT Server,或服务 UUID 未在advertising packet中广播。discoverServices()依赖设备主动响应 ATT 请求,若设备固件有缺陷(如未处理Find Information Request),则永远卡住。
解决:改用BluetoothGattServer模式反向验证——用另一台 Android 设备作为 GATT Server 广播服务,确认本机 Client 能正常发现。若能发现 Server,则问题在目标设备固件。

5.3 现象:SPP 连接成功,但OutputStream.write()抛IOException: Broken pipe

原因:远端设备(如单片机)在连接建立后未及时调用accept()或listen(),导致 TCP 层 RST 包返回。Android 底层将其映射为Broken pipe。
解决:在远端设备侧增加连接确认机制——SPP 连接后,Android 先发送0xFF 0x00心跳,远端收到后回复0x00 0xFF,双方均确认后再进入数据传输。此心跳必须在connect()成功后 500ms 内完成。

5.4 现象:Android 13 设备上BluetoothLeScanner.startScan()报SecurityException: Need ACCESS_FINE_LOCATION,但权限已申请

原因:ACCESS_FINE_LOCATION权限虽已授予,但系统设置中“位置信息”总开关被关闭(Settings > Location > toggle off)。Android 13 将此视为权限未满足。
解决:在申请NEARBY_DEVICES前,用LocationManager.isLocationEnabled()检测,并引导用户跳转设置页:

if (!locationManager.isLocationEnabled()) { startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }

5.5 现象:BLE 通知(notification)开启后,onCharacteristicChanged()仅触发一次,后续无回调

原因:BluetoothGatt.setCharacteristicNotification()调用后,未紧接着writeDescriptor()写入 CCCD。部分设备(尤其 Nordic nRF52 系列)要求 CCCD 值必须为0x0001(enable notify)且写入成功,否则忽略后续通知。
解决:严格按顺序执行——先setCharacteristicNotification(true),再getDescriptor(CCCD_UUID),再writeDescriptor(),并在onDescriptorWrite()中确认status == GATT_SUCCESS。

6. 生产就绪技巧:用BluetoothGatt的refresh()方法解决连接漂移与缓存污染

6.1 为什么你需要refresh():GATT 缓存不是帮你,是在拖你后腿

Android 系统为提升 BLE 性能,默认缓存设备的 GATT 服务拓扑。这带来一个隐蔽陷阱:当设备固件升级后,服务 UUID 或特征值长度变更,但 Android 仍沿用旧缓存,导致gatt.readCharacteristic()返回null或GATT_INVALID_ATTRIBUTE_LENGTH。更糟的是,refresh()是隐藏 API,官方文档从未提及,但它是解决此问题的唯一合法手段。

private fun refreshGattCache(gatt: BluetoothGatt): Boolean { return try { val refreshMethod = gatt.javaClass.getMethod("refresh") val result = refreshMethod.invoke(gatt) as Boolean Log.d("BLE", "GATT cache refreshed: $result") result } catch (e: Exception) { Log.e("BLE", "Refresh failed", e) false } } // 在连接成功后、discoverServices前调用 override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED) { refreshGattCache(gatt) // 关键!清空旧缓存 gatt.discoverServices() } }

逻辑说明:refresh()强制清空本地 GATT 缓存,迫使discoverServices()重新从设备读取完整服务表。参数说明:该方法返回true表示缓存已清空,但不保证后续discoverServices()成功——它只是移除错误前提。

6.2 构建可中断的连接状态机:用AtomicBoolean控制生命周期,避免内存泄漏

Demo 很容易写成“连接 → 启动线程 → 线程里死循环读”,结果 Activity 销毁后线程仍在后台运行,持有 Activity Context 导致 OOM。正确做法是用AtomicBoolean标记连接状态,并在onDestroy()中主动中断。

private val isConnected = AtomicBoolean(false) private var readThread: Thread? = null private fun startReading(socket: BluetoothSocket) { isConnected.set(true) readThread = Thread { val input = BufferedInputStream(socket.inputStream) val buffer = ByteArray(1024) while (isConnected.get() && !Thread.currentThread().isInterrupted) { val len = input.read(buffer) if (len > 0) { handleData(buffer.copyOfRange(0, len)) } } } readThread?.start() } override fun onDestroy() { super.onDestroy() isConnected.set(false) // 标记停止 readThread?.interrupt() // 中断线程 gatt?.close() // 关闭 GATT bluetoothSocket?.close() // 关闭 SPP socket }

注意:AtomicBoolean比volatile boolean更可靠,因为它保证了get()/set()的原子性,避免多线程下状态判断错乱。

6.3 日志分级与可追溯性:给每条蓝牙日志打上设备 ID 与操作阶段标签

没有上下文的日志等于没有日志。在onConnectionStateChange、onServicesDiscovered、onCharacteristicRead等所有回调中,必须打印设备 MAC 地址和当前阶段,否则排查时你根本分不清是哪台设备、哪个连接出了问题。

private fun logBleEvent(event: String, gatt: BluetoothGatt, stage: String) { val mac = gatt.device?.address ?: "UNKNOWN" Log.d("BLE:$mac:$stage", event) } // 在回调中调用 override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { logBleEvent("State change: $newState, status: $status", gatt, "CONNECT") }

表格:关键日志标签规范

标签前缀触发场景示例日志
BLE:AA:BB:CC:DD:EE:FF:CONNECT连接状态变更State change: 2, status: 0
BLE:AA:BB:CC:DD:EE:FF:DISCOVER服务发现完成Services discovered: 3 services
BLE:AA:BB:CC:DD:EE:FF:READ特征值读取Read success: [0x01, 0x02]
SPP:AA:BB:CC:DD:EE:FF:WRITESPP 数据写入Write 12 bytes to output stream

我带过的三个项目里,有两次重大线上故障,都是靠这种带 MAC 地址的日志,在 10 分钟内定位到是某批次设备固件的 CCCD 响应超时。后来我把这套日志规范写进了团队《蓝牙开发红线手册》第一条。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 23:00:39

从朴素贝叶斯到N-gram:构建语言模型的概率方法论

作为 NLP 系列的第 5 篇&#xff0c;我想聊一个承上启下的关键转折&#xff1a;从朴素贝叶斯到 N-gram 语言模型。前面我们用朴素贝叶斯做文本分类&#xff0c;靠的是“词袋 条件独立”这两个假设&#xff1b;但一旦你想让模型理解词序、生成自然句子&#xff0c;或者给一句话…

作者头像 李华
网站建设 2026/10/16 23:00:31

DeepSeek提示词实战指南:从复制模板到写出高质量Prompt

简介&#xff1a;《DeepSeek高级应用指南》是一份面向AI工具新手与职场效率提升者的提示词实战手册。内容围绕职场、自媒体、电商、程序员、副业、个人成长等常见场景&#xff0c;整理出50组可直接套用的高阶提示词&#xff0c;帮助读者摆脱“不会提问、回答泛泛”的困境&#…

作者头像 李华
网站建设 2026/10/16 23:00:11

成为AI工程师:从数学基础到模型部署的完整技能路线图

1. 先理清&#xff1a;人工智能工程师到底在忙什么这几年经常有人问我&#xff1a;想成为人工智能工程师&#xff0c;到底要先学什么&#xff1f;这个问题听起来简单&#xff0c;回答起来却非常费劲。AI工程师不是一个只写模型的岗位&#xff0c;它更像一个连接算法、数据、业务…

作者头像 李华
网站建设 2026/10/16 23:00:06

从requests到BeautifulSoup:豆瓣Top250电影爬虫实战

爬取豆瓣评分前50的电影信息&#xff0c;这个需求我太熟了。前阵子帮一位朋友做可视化课程作业&#xff0c;他要的就是这个——把豆瓣评分最高的前50部电影整理成表格&#xff0c;再画个评分分布图。我一听&#xff0c;这事看着简单&#xff0c;真做起来全是坑。豆瓣的反爬不是…

作者头像 李华
网站建设 2026/10/16 22:59:58

专有云DTS API调用实战:从凭证准备到任务编排

简介&#xff1a;阿里云专有云企业版 V3.16.0 数据传输服务DTS开发指南是一份面向企业开发者和系统集成工程师的官方产品文档&#xff0c;专注于DTS API接口的完整调用方法与集成流程。资源压缩包共1个PDF文件&#xff0c;大小3.57MB&#xff0c;便于检索和离线翻阅&#xff0c…

作者头像 李华
网站建设 2026/10/16 22:59:48

ASP.NET MVC 5项目源码包:含权限、数据库、JWT改造的完整站点

简介&#xff1a;本资源是面向高校计算机专业学生及.NET初学者的ASP.NET网站开发实战教程配套源码包&#xff0c;聚焦图书管理系统这一典型Web项目&#xff0c;系统覆盖页面生命周期、MVC架构、C#核心编程、ADO.NET与Entity Framework数据库操作、Web API构建及IIS部署等关键技…

作者头像 李华