背景
某天直属领导找到我,要我和iOS开发一起看一下蓝牙通信,后续可能要在2台设备的2个App之间实现消息的传递。大致上就是把工行转账用的那个U盾给换成一个手机。
阅读所需最小必要知识
- 了解广播的动态注册、监听
- 了解申请权限
- 了解Handler的消息发送、接收
- 了解UUID可以随机生成和从字符串转化
技术分歧
和我搭档的iOS开发曾经具有嵌入式设备开发经验,他告诉我说“这个需求做不了”,要设备的Mac地址还有好几个UUID,这些都是硬件给出的,但是在手机上因为隐私保护拿不到。 不需要硬件给出,明明是自己约定的。
而我搜索了一下,蓝牙分为经典蓝牙和低功耗蓝牙(BLE)这两种,低功耗蓝牙才是需要Mac地址和好几个UUID的,经典蓝牙只需要双方约定好同一个UUID,一个等待连接,一个主动连接即可。因此确定了后续的搜索方向为经典蓝牙。
勘误:iOS设备不支持经典蓝牙,使用低功耗蓝牙BLE也可以实现手机间的通信,请参考这篇BLE蓝牙完成手机之间的通信,记一次技术预研_2 - 掘金 (juejin.cn)
实现过程
- 配对
- 连接
- 发送消息
- 解除连接
- 解除配对(可选)
配对和解除配对
手机设置配对或者代码中配对,任选其一即可。我这次只是个演示demo,就使用了手机设置中配对。
手机设置配对: 2个手机均打开设置-蓝牙,找到对方后点击配对即可,略
代码中配对:
涉及反射,搜索来的,我没有验证过,请按需选用
配对代码,解除配对把createBond换成removeBond即可
Method createBondMethod = btClass.getMethod("createBond");
Boolean returnValue = (Boolean) createBondMethod.invoke(btDevice);
return returnValue.booleanValue();
连接
在经典蓝牙的体系中,设备分为服务端和客户端,双方在这个demo场景下并无高下之分,只是用的API不一样。注意这里的服务端并不是说服务器接口。
两端都要申请权限,大概是因为蓝牙可以用来辅助定位?所以需要定位权限?:
private static final String[] Permission = {
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION,
};
别忘了Manifest文件:
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- 如果需要app在没有硬件时不能安装,就把false改成true-->
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="false" />
拿到权限之后检查蓝牙硬件、蓝牙和GPS开关:
public class BlueToothHelper {
private static final String TAG = "BlueToothHelper";
public static boolean checkHasBlueToothHardware(Context context) {
if (context == null) {
return false;
}
return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH);
}
public static boolean checkAll(Context context) {
if (context == null) {
return false;
}
if (!enable(context)) {
Log.e(TAG, "BleHelp初始化失败:" + "(用户操作)未打开手机蓝牙,蓝牙功能无法使用......");
Toast.makeText(context, "未打开手机蓝牙,蓝牙功能无法使用...", Toast.LENGTH_LONG).show();
return false;
}
if (!isOPenGps(context)) {
Log.e(TAG, "BleHelp初始化失败:" + "(用户操作)GPS未打开,蓝牙功能无法使用...");
Toast.makeText(context, "GPS未打开,蓝牙功能无法使用", Toast.LENGTH_LONG).show();
return false;
}
return true;
}
/**
* 打开手机蓝牙
*
* @return true 表示打开成功
*/
public static boolean enable(Context context) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH) != PackageManager.PERMISSION_GRANTED) {
return false;
}
if (!getBluetoothAdapter().isEnabled()) {
//若未打开手机蓝牙,则会弹出一个系统的是否打开/关闭蓝牙的对话框,禁止或者未处理返回false,允许返回true
//若已打开手机蓝牙,直接返回true
if (ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADMIN) != PackageManager.PERMISSION_GRANTED) {
return false;
}
boolean enableState = getBluetoothAdapter().enable();
Log.d(TAG, "(用户操作)手机蓝牙是否打开成功:" + enableState);
return enableState;
} else {
return true;
}
}
/**
* 判断GPS是否开启,GPS或者AGPS开启一个就认为是开启的
*
* @return true 表示开启
*/
public static boolean isOPenGps(Context context) {
LocationManager locationManager
= (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
// 通过GPS卫星定位,定位级别可以精确到街(通过24颗卫星定位,在室外和空旷的地方定位准确、速度快)
boolean gps = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER);
// 通过WLAN或移动网络(3G/2G)确定的位置(也称作AGPS,辅助GPS定位。主要用于在室内或遮盖物(建筑群或茂密的深林等)密集的地方定位)
boolean network = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER);
if (gps || network) {
Log.d(TAG, "GPS状态:打开");
return true;
}
Log.e(TAG, "GPS状态:关闭");
return false;
}
public static BluetoothAdapter getBluetoothAdapter() {
return BluetoothAdapter.getDefaultAdapter();
}
}
服务端
检查过权限和设备开关之后,服务端就可以单独开一个线程,等待设备连接、连接完成之后通过inputStream读取数据(也可以使用outputStream写数据给客户端)
不需要读写数据的时候记得把线程关闭。
public class AcceptThread extends Thread {
private BluetoothServerSocket serverSocket;
private BluetoothSocket socket;
private InputStream is;
private OutputStream os;
private boolean needStop = false;
private final UUID blueToothUUID = UUID.fromString("这个UUID要使用和客户端一致的");
public static final int MSG_WHAT_GET_DATA = 1;
private Handler handler;
public AcceptThread(Handler handler, BluetoothAdapter bluetoothAdapter) {
this.handler = handler;
//创建BluetoothServerSocket对象
try {
serverSocket = bluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord("name", blueToothUUID);
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void run() {
//等待接受蓝牙客户端的请求
Log.d("AcceptThread", "线程开始");
try {
socket = serverSocket.accept();
is = socket.getInputStream();
os = socket.getOutputStream();
while (!needStop) {
byte[] buffer = new byte[128];
int count = is.read(buffer);
Message message = new Message();
message.what = MSG_WHAT_GET_DATA;
message.obj = new String(buffer, 0, count, "utf-8");
if (handler != null) {
handler.sendMessage(message);
}
}
} catch (IOException e) {
e.printStackTrace();
}
Log.d("AcceptThread", "线程结束");
}
public void stopThread() {
needStop = true;
try {
if (serverSocket != null) {
serverSocket.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
线程之外,Activity收到Handler发送来的消息之后,就可以做展示或者别的处理了。
客户端
一样是先申请权限、检查开关,全部就绪之后搜索设备:
这2个方法是客户端的BlueToothHelper的静态方法,getBluetoothAdapter()已在服务端的方法中出现过了,不再重复贴代码
public static void startDiscovery() {
getBluetoothAdapter().startDiscovery();
}
public static void stopDiscovery() {
getBluetoothAdapter().cancelDiscovery();
}
搜索设备之前先注册广播监听,然后在广播监听中,搜到了设备,进行连接。代码中的changeStatus只是一个修改TextView文字的方法,无需理会。
deviceName是要连接的设备名称;
first只是控制是否首次连接的变量,因为会重复发现设备,重复连接同一台设备会直接抛异常导致连接失败。
这里先是填写设备名称再搜索并连接的,真实需求中常用的方式是先搜索设备,展示到一个列表中,然后选择一台设备进行连接。
private final BroadcastReceiver mReceiver = 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);
Log.i(TAG, "发现设备" + device.getName() + " " + device.getAddress());
if (TextUtils.equals(deviceName, device.getName()) && first) {
first = false;
BlueToothHelper.stopDiscovery();
connectDevice(device);
}
} else if (BluetoothAdapter.ACTION_DISCOVERY_STARTED.equals(action)) {
Log.d(TAG, "开始搜索");
} else if (BluetoothAdapter.ACTION_DISCOVERY_FINISHED.equals(action)) {
Log.d(TAG, "搜索完毕");
}
}
};
private void startDiscovery() {
IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_FOUND);
filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_STARTED);
filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED);
registerReceiver(mReceiver, filter);
changeStatus("正在搜索设备");
BlueToothHelper.startDiscovery();
}
private void connectDevice(BluetoothDevice device) {
if (isConnected) {
return;
}
synchronized (this) {
if (isConnected) {
return;
}
Log.d(TAG, "connectDevice, 开始连接设备");
new Thread() {
@Override
public void run() {
try {
Log.d(TAG, "connectDevice, 进入线程");
BluetoothSocket bluetoothSocket =
device.createRfcommSocketToServiceRecord(UUID.fromString(UUID_BLUE_TOOTH_SERVICE));
bluetoothSocket.connect();
BlueToothCommunicationActivity.this.bluetoothSocket = bluetoothSocket;
isConnected = true;
changeStatus("设备已连接");
} catch (IOException e) {
e.printStackTrace();
isConnected = false;
changeStatus("设备连接出错,请重试");
}
Log.d(TAG, "connectDevice, 退出线程");
}
}.start();
}
}
发送消息
客户端使用如下代码发送消息:
btSend.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (!isConnected) {
return;
}
String content = etContent.getText().toString();
try {
bluetoothSocket.getOutputStream().write(content.getBytes());
} catch (IOException e) {
e.printStackTrace();
}
}
});
服务端在之前那个线程中接收消息。
解除连接
- 服务端使用stopThread()方法,断开连接并且停止线程。
- 客户端使用serverSocket.close()断开连接。
解除配对(可选)
在设置中解除配对,或者在两端使用代码反射解除配对。
踩的坑
- 发现设备时会每台设备收到2个发现广播,不经处理直接连接设备会导致使用connect()方法直接抛异常。
编辑历史
- 2024年2月1日:补上遗漏的Manifest文件内容
- 2024年2月29日:关于BLE的勘误,并给出BLE的文章地址