资讯详情

资讯详情

Android经典蓝牙开发:BluetoothChat源码解析与RFCOMM通信实践

简介这是一份面向Android开发者的蓝牙聊天应用源码包特别适合刚接触蓝牙通信、想通过实际项目入门的初中级开发者。源码以完整应用为线索覆盖了从蓝牙权限声明、蓝牙适配器初始化到设备扫描与配对、基于蓝牙套接字建立连接、通过输入输出流进行双向消息收发再到界面布局、广播接收和异常处理的全部关键环节能够直观展示Android蓝牙聊天的实现流程。压缩包共52个文件以Java源码、XML布局与配置、编译后的class文件为主同时附有可直接安装体验的APK、界面截图、源码说明及工程配置文件整体大小约4.14MB方便按模块阅读和真机调试借助源码说明还能快速定位关键代码位置节省环境排错时间。目前已有191人学习使用通过运行调试可清楚观察设备列表、配对状态和聊天消息实时展示是系统梳理Android蓝牙编程知识的实用参考。1. 拆一份 BluetoothChat 改进版经典蓝牙的完整骨架这个压缩包是 Android 蓝牙聊天应用的完整源码文件截图时间戳指向 2013 年是基于系统自带 BluetoothChat 示例改的版本。把 AndroidManifest.xml、BluetoothChatService 和界面三层过一遍会发现它对经典蓝牙BR/EDR链路覆盖得很完整设备发现、配对、RFCOMM Socket 建连、流式读写、断线回收每一步都能在源码里找到落点。它适合想在老设备上跑离线聊天工具的用户也适合想弄清蓝牙应用层协议的开发者。后者应重点看三个线程的配合方式和 UI 状态机这套设计放到 BLE 开发里依然成立。下面按源码实际调用顺序拆权限与设备发现、Socket 与 UUID、数据线程设计、新版系统适配。2. 蓝牙权限声明、BluetoothAdapter 与设备发现2.1 权限声明老权限与新运行时权限的差异源码里 AndroidManifest.xml 用的还是 2013 年那套BLUETOOTH和BLUETOOTH_ADMIN权限。这两个权限在 API 31 之前由系统在安装时直接授予不需要运行时弹窗所以那个年代的项目写到这里就结束了。如果现在用 Android Studio 把这些代码跑到 API 31 以上的设备上扫描阶段会直接抛 SecurityException。Android 12 开始Google 把蓝牙权限拆成了BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE三个运行时权限扫描、建连、广播分别授权。兼容写法是在老权限上加maxSdkVersion限制再叠加新权限uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION /两组权限同时存在时系统按 SDK 版本选择生效的那组30 及以下走老权限31 以上走新权限。ACCESS_FINE_LOCATION这条是给 6.0 到 11 那段过渡期补的蓝牙扫描结果在 Android 11 及以下被归类为位置信息不授权定位权限就永远扫不到设备。如果应用只做文本聊天不做测距BLUETOOTH_SCAN上还可以加usesPermissionFlagsneverForLocation声明授权弹窗里不会出现敏感的位置文案。提示neverForLocation只能加在 API 31 的BLUETOOTH_SCAN上加了之后系统认为你的扫描不推断位置。如果后续要接 RSSI 测距、室内定位这类功能不要加这个声明。2.2 BluetoothAdapter 状态检查先判空再启用源码获取适配器的写法至今没有变化BluetoothAdapter.getDefaultAdapter()。常见错误是拿到就直接startDiscovery()在一台不具备蓝牙硬件的设备上会当场抛异常。正确做法是先判空再判断蓝牙是否打开未打开时通过ACTION_REQUEST_ENABLE拉起系统的启用授权页。BluetoothAdapter adapter BluetoothAdapter.getDefaultAdapter(); if (adapter null) { Toast.makeText(this, 设备不支持蓝牙, Toast.LENGTH_SHORT).show(); finish(); return; } if (!adapter.isEnabled()) { Intent enableBt new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); startActivityForResult(enableBt, REQUEST_ENABLE_BT); }REQUEST_ENABLE_BT是自定义的整型常量onActivityResult里判断resultCode RESULT_OK后再调用startDiscovery()。注意在 Android 13API 33及以上isEnabled()和startDiscovery()都要求先拿到BLUETOOTH_CONNECT运行时权限否则即使蓝牙开着也会抛 SecurityException。调用顺序应当是运行时权限对话框 → 确认授权 → 检查 adapter 状态 → 发起扫描。2.3 设备发现广播接收者、去重与列表分组startDiscovery()是异步扫描结果以系统广播形式不断回传核心监听动作是BluetoothDevice.ACTION_FOUND。源码的写法是注册一个 BroadcastReceiver在onReceive里取 Intent 中的EXTRA_DEVICE字段得到BluetoothDevice对象再按getBondState()区分已配对和未配对设备。private final BroadcastReceiver receiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { String action intent.getAction(); if (BluetoothDevice.ACTION_FOUND.equals(action)) { BluetoothDevice device intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); if (device.getBondState() BluetoothDevice.BOND_BONDED) { bondedDevices.add(device); } else { newDevices.add(device); } adapter.notifyDataSetChanged(); } else if (BluetoothAdapter.ACTION_DISCOVERY_FINISHED.equals(action)) { // 扫描结束停掉进度条恢复按钮可点 } } };两个容易被忽略的点一是广播里同一台设备可能出现多次添加前要去重常见做法是维护一个SetString记录设备地址二是扫描结束时必须处理ACTION_DISCOVERY_FINISHED把“扫描中”的进度条停掉并恢复按钮否则界面一直处于加载态。列表里把已配对和未配对分组显示这个设计在排查蓝牙模块问题时很有用已配对但连不上的设备与未配对连不上的设备排查方向完全不同。3. RFCOMM Socket 与 UUID 选型连接建立的取舍从连接建立的顺序看Socket 通道的建立分成两个动作一是本地创建BluetoothSocket对象二是与远端设备寻址握手。源码里这两个动作分别落在 ConnectThread 和 AcceptThread 里这章把它拆开讲重点说安全连接与非安全连接的取舍、UUID 的作用以及失败时的排查方向。3.1 安全连接与非安全连接差异不在加密强度蓝牙设备在应用层的连接入口是createRfcommSocketToServiceRecord()和createInsecureRfcommSocketToServiceRecord()两个 API分别对应安全连接与非安全连接。安全连接会走系统配对流程要求两端完成身份认证非安全连接跳过 PIN 码输入这一环由系统内部的 SSP 机制自动处理。很多资料把这两个 API 说成“加密”和“不加密”这是误导——非安全连接传输同样有蓝牙链路层加密它跳过的只是配对交互这一步。// 非安全连接不弹配对框适合对接 HC-05 等串口模块 BluetoothSocket socket device.createInsecureRfcommSocketToServiceRecord(uuid); // 安全连接需要 PIN 码输入或配对确认 BluetoothSocket secureSocket device.createRfcommSocketToServiceRecord(uuid);改版源码固定走非安全连接这个取舍可以理解如果要连的是 HC-05 模块默认 PIN 是 1234安全连接的弹窗输入流程在部分 ROM 上非常慢非安全连接几乎点击即连。但如果是两台手机互相聊天建议换成安全连接否则会暴露一个真实存在的安全问题仿冒设备。所谓“安全”与否本质上是身份验证强度的差异。3.2 ConnectThread连接前的顺序与线程安全连接流程里关键并不在socket.connect()那一步而在它之前的cancelDiscovery()。2.3 里提到startDiscovery()是持续性异步扫描扫描过程中射频资源被反复占用此时发起connect()会出现两种现象要么长时间阻塞直到系统超时要么对端日志里出现“link supervision timeout”。因此无论源码怎么重构ConnectThread 的run()里第一件事永远是cancelDiscovery()。private class ConnectThread extends Thread { private final BluetoothSocket socket; ConnectThread(BluetoothDevice device, boolean secure, UUID uuid) { BluetoothSocket tmp null; try { tmp secure ? device.createRfcommSocketToServiceRecord(uuid) : device.createInsecureRfcommSocketToServiceRecord(uuid); } catch (IOException e) { Log.e(TAG, 创建 Socket 失败, e); } socket tmp; } Override public void run() { BluetoothAdapter.getDefaultAdapter().cancelDiscovery(); try { socket.connect(); synchronized (BluetoothChatService.this) { connected(socket, device); } } catch (IOException e) { Log.e(TAG, 连接失败, e); try { socket.close(); } catch (IOException e2) { // 二次异常不需要处理 } } } }连接成功后的处理链路是socket.connect()返回后把 socket 交给BluetoothChatService.connected()方法由该方法创建 ConnectedThread 开始数据传输同时把 AcceptThread 和 ConnectThread 的引用清掉。这里的synchronized块很重要——防止连接回调与断开回调同时修改 Service 内部状态这是官方示例里就有的细节改版源码保留了下来。如果你自己重写这行锁别删。3.3 UUID 不匹配与连接失败排查UUID 用来告诉对端“我要连哪种服务”。在 RFCOMM 上真正生效的是 SPP 标准 UUID00001101-0000-1000-8000-00805F9B34FB。如果两端都是 Android 且跑同一份源码UUID 只要两端一致就行自定义值是允许的但如果对端是单片机或串口模块UART 侧固件只监听 SPP 服务换任何自定义 UUID 都会被拒绝。失败现象常见原因排查方向connect() 抛 read failed, socket might closed扫描未取消或对端未处于可发现状态确认 cancelDiscovery() 在 connect() 之前执行连接建立后被立即断开两端 UUID 不一致服务端没有对应 accept 通道统一使用 SPP 标准 UUID一直停在“正在连接”对端模块未进入透传模式用串口助手确认模块状态灯是否常亮扫不到目标设备API 23 缺少定位权限或蓝牙不可发现检查运行时权限和系统蓝牙“可检测性”系统弹配对框但数据不通安全连接等待对端确认模块无确认入口改用 Insecure 发起连接这五类问题覆盖了实际调试中 80% 的失败场景。特别注意第一行read failed字面看像读取失败但绝大多数时候发生在connect()阶段本质是链路层超时。网络里被反复引用的“连接中断后必须 close socket”就是针对这一现象的修复手段。4. 从字节流到聊天框线程模型与 UI 状态机4.1 ConnectedThread 的阻塞式读取连接建立后数据链路交给 ConnectedThread。它的核心逻辑是死循环读输入流inputStream.read(buffer)是阻塞调用没有数据时线程挂起有数据时返回实际读到的字节数直到 socket 被关闭或链路中断抛出 IOException 才退出。源码里每次读取后都通过 Handler 发消息通知 UI 线程更新聊天列表。private class ConnectedThread extends Thread { private final BluetoothSocket socket; private final InputStream in; private final OutputStream out; ConnectedThread(BluetoothSocket socket) { this.socket socket; InputStream tmpIn null; OutputStream tmpOut null; try { tmpIn socket.getInputStream(); tmpOut socket.getOutputStream(); } catch (IOException e) { Log.e(TAG, 获取流失败, e); } in tmpIn; out tmpOut; } Override public void run() { byte[] buffer new byte[1024]; int bytes; while (true) { try { bytes in.read(buffer); mHandler.obtainMessage(MSG_READ, bytes, -1, buffer).sendToTarget(); } catch (IOException e) { connectionLost(); break; } } } void write(byte[] bytes) { try { out.write(bytes); } catch (IOException e) { Log.e(TAG, 写失败, e); } } }这段代码有两个边界需要注意。第一Handler 收到消息时buffer 的内容可能已经被下一轮read()覆盖如果直接拿 buffer 转字符串消息一多就是乱序或乱码。标准做法是在sendToTarget()之前把数据复制出来或者用new String(buffer, 0, bytes)转成字符串并把长度放进Message.arg1。第二1024 字节的缓冲区对文本消息够用但对图片或文件传输就不够了输入流是流式的粘包和拆包逻辑需要自己处理。4.2 状态机UI 与连接线程之间的消息通道蓝牙聊天这种场景界面必须跟上连接状态。源码在 BluetoothChatService 里维护了STATE_NONE、STATE_LISTEN、STATE_CONNECTING、STATE_CONNECTED四个状态状态变化全部通过 Handler 发到 UI 线程由handleMessage统一处理界面切换。case MESSAGE_STATE_CHANGE: switch (msg.arg1) { case STATE_CONNECTED: mTitle.setText(已连接); mSendButton.setEnabled(true); mConnectButton.setEnabled(false); break; case STATE_CONNECTING: mTitle.setText(连接中); mSendButton.setEnabled(false); break; case STATE_LISTEN: mTitle.setText(等待连接); mConnectButton.setEnabled(true); break; } break; case MESSAGE_READ: byte[] readBuf (byte[]) msg.obj; String text new String(readBuf, 0, msg.arg1, StandardCharsets.UTF_8); mConversation.add(对方 text); break;状态机的价值在于所有子线程都能通过 Handler 发消息而界面渲染只在这里处理不会出现多个线程同时改 UI 的崩溃。MESSAGE_READ里msg.arg1就是 4.1 提到的字节长度new String(readBuf, 0, msg.arg1)保证只截取有效数据段。如果直接new String(readBuf)会把 1024 字节缓冲区后面的空内容全带进界面这就是很多改版源码乱码和后半段空白字符的来源。4.3 断线重连与资源回收蓝牙断线有两种物理断开距离超出范围和协议断开对端主动关闭 socket。两种情况下read()都会抛 IOException源码在 catch 里调用connectionLost()最终回到STATE_LISTEN等待重新连接。这里有个值得说的细节旧版把socket.close()塞在 finally 块里但断开时只触发了已完成连接的引用释放AcceptThread 仍留在原地等待快速断开再连接时可能出现状态错乱。重连机制不是源码里现有的功能。断开后建议自己加一层策略记录当前对端设备地址断线后延迟 23 秒调用 connect 重试重试超过 5 次再回到扫描界面。调重试间隔时注意蓝牙协议栈对同一设备的连续 connect 是有节流限制的1 秒内多次请求大概率被系统忽略。这属于从示例代码往工程代码改造时最常见的隐藏坑。5. 老源码迁移到 Android 12权限适配与排错技巧5.1 新权限模型下的完整授权链路把这份源码从 Android 4.x 迁到 Android 12核心改动是权限申请时序。正确顺序是请求BLUETOOTH_CONNECT→ 请求BLUETOOTH_SCAN→ adapter 状态判断 →startDiscovery()。BLUETOOTH_SCAN的授权弹窗要和具体扫描动作绑定不要放在onCreate里一股脑全请求。private void requestBtPermissions() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { ActivityCompat.requestPermissions(this, new String[]{ Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN }, REQUEST_BT_PERMISSION); } }还有一个容易被忽略的变化ACTION_REQUEST_ENABLE从 API 33 起标记废弃。新写法需要在拿到BLUETOOTH_CONNECT权限后用registerReceiver监听BluetoothAdapter.ACTION_STATE_CHANGED再调用adapter.enable()等待广播回调确认蓝牙已开启。老 API 目前还能运行但 targetSdk 35 编译时会被 lint 提示 deprecated建议直接换新写法。5.2 配对状态与连接状态是两回事最后一个实际技巧createBond()的返回值只代表配对请求已提交不代表配对完成。正确监听方式是注册ACTION_BOND_STATE_CHANGED广播在onReceive里判断getBondState()是否为BOND_BONDED。这个判断直接关系到下一步 socket 连接的成功率很多蓝牙模块连不上的案例就是因为代码里createBond()返回 true 就立刻发connect()此时模块还在等待 PIN 确认socket 连接自然失败。当你把这套源码完整跑通再回头看会发现经典蓝牙与 BLE 只是传输层的两个外壳。把这里的 AcceptThread 换成GattServerCallback把 ConnectedThread 换成onCharacteristicChanged回调界面层几乎不用动——连接生命周期、状态机、UI 回调、权限申请这些逻辑是相通的这才是这份老源码最值得带走的部分。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →