今天來聊一下關於Android 中的藍芽開發吧!
近年來的藍芽技術不斷發展,廣為人知的從2.0版的經典藍芽,到4.0低功耗藍牙
最後來到現在的5.0藍芽,可見技術真的不斷在發展然後工程師的白頭髮越來越多
而今天的主旨不在闡述什麼藍芽的歷史這種大眾都可以了解的嘗試,而是來闡述關於"Android手機的藍芽開發"
其實我在學期間就已經多次開發應用藍芽相關技術了,但是很遺憾,當時也是一知半解地學,其實還是很不熟...
然後就這麼到了出來工作,公司在藍芽連接設備上採用的是低功耗藍牙BLE的相關技術,對我一個菜逼八真的是不小的挑戰(只是自己弱好嗎...)
當時也算是從頭研究,加上第一份工作又是剛上工,壓力山大啊...( ゜ρ゜)
閒聊到此,今天要撰寫的是關於藍芽BLE在Android中裝置上的開發
因為我在這項技術上吃了不少苦頭,故將之記錄下來
首先在閱讀本文前,也要先告訴大家幾件事
1. 本文沒有一定程度的Java知識會比較難閱讀
2. 本文會有其他UI方面的相關技術,我都會貼給你
3. 建議不要只看我這篇文章,我後面貼的相關文章也一並參考會比較能理解
4. 本文將會介紹藍芽開發中的搜尋裝置、裝置連線、讀取藍芽資料與讀取藍芽資料這4大環節,但由於篇幅過長,故分為上下兩篇
以下是其他參考資料
Google官方源碼Demo(我試過了可以執行,請記得手動開定位權限)
->https://github.com/googlesamples/android-BluetoothLeGatt
官方介紹
->https://developer.android.com/guide/topics/connectivity/bluetooth-le.html
至於今天Demo用到的藍芽裝置,我是自己買Arduino跟藍芽晶片來自己寫,詳情請參考這一篇
此外,(上)篇的進度只會到搜尋藍芽並顯示的部分,請特別注意⚠️
(備註:本文之程式碼內容於2021/6/19號更新過)
好的,貼完參考資料後,來看一下今天的功能吧
以及Github
->https://github.com/thumbb13555/BLE_Example/tree/master
1.載入相關權限
原理我真的不想介紹了...網路隨便搜一篇都寫得比我好QQ
針對Android的部分,首先要確認的權限有以下幾個
1.手機版本是否高於API18(Android 4.3)
2.是否支援藍芽?(通常高過4.3就有)
3.是否開啟藍芽?
4.是否開啟定位?(BLE藍芽需要有定位才可以掃到裝置)
再來,展示一下今天進度的所有需要的檔案
確認完以上後,以下就是該加入的內容
首先是AndroidManifest.xml的部分
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.jetec.ble_example"> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" /> <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" /> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> <activity android:name=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>
其中<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
便是開啟藍芽啟用權,其他便是藍芽權限與取得手機位置權限等
再來來到MainActivity.java
onCreate中加入下面程式
/**確認手機版本是否在API18以上,否則退出程式*/ if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){ /**確認是否已開啟取得手機位置功能以及權限*/ int hasGone = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION); if (hasGone != PackageManager.PERMISSION_GRANTED) { requestPermissions( new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, REQUEST_FINE_LOCATION_PERMISSION); } /**確認手機是否支援藍牙BLE*/ if (!getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { Toast.makeText(this,"Not support Bluetooth", Toast.LENGTH_SHORT).show(); finish(); } /**開啟藍芽適配器*/ if(!mBluetoothAdapter.isEnabled()){ Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); startActivityForResult(enableBtIntent,REQUEST_ENABLE_BT); } }else finish();
忘記補上,全域變數可以先宣告如下
public class MainActivity extends AppCompatActivity { private static final String TAG = MainActivity.class.getSimpleName()+"My"; BluetoothAdapter mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); private static final int REQUEST_FINE_LOCATION_PERMISSION = 102; private static final int REQUEST_ENABLE_BT = 2; private boolean isScanning = false; ArrayList<ScannedData> findDevice = new ArrayList<>(); RecyclerViewAdapter mAdapter; @Override
這時候執行,就會看到這個畫面囉!
2. 畫介面
兩個介面一次送你(・ωー)~☆
<?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"> <androidx.appcompat.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="60dp" android:background="@color/colorPrimary" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="掃描裝置" android:textColor="@android:color/white" android:textSize="20dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <Button android:id="@+id/button_Scan" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="8dp" android:background="@android:color/transparent" android:text="停止掃描" android:textColor="@android:color/white" android:textStyle="bold" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout> </androidx.appcompat.widget.Toolbar> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView_ScannedList" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/toolbar" />
(RecyclerView用的item)
<?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="wrap_content"> <TextView android:id="@+id/textView_DeviceName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginTop="16dp" android:text="裝置名稱" android:textAppearance="@style/TextAppearance.AppCompat.Large" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/textView_Address" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginTop="8dp" android:text="位址Address" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textView_DeviceName" /> <TextView android:id="@+id/textView_ScanRecord" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginTop="8dp" android:layout_marginBottom="16dp" android:text="挾帶的資訊ScanRecord" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="@+id/textView_Rssi" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textView_Address" /> <TextView android:id="@+id/textView_Rssi" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="訊號強度Rssi" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@+id/textView_DeviceName" /> </androidx.constraintlayout.widget.ConstraintLayout>
3. 撰寫功能
3-1 建立實體類別ScannedData.java
一般來說,在藍芽掃描的時候可以獲得以下資訊
1.裝置名稱
2.位址(這很重要,除了連線要用以外,他也是所有裝置的唯一碼)
3.Rssi(訊號強度)
4.挾帶的資訊(以一串byteArray傳輸,部分韌體工程師會將部分需告知的資訊放在這邊,提供終端必要資訊)
也因此,我們的實體類別就要有這四個資訊
class ScannedData { /**這邊是拿取掃描到的所有資訊*/ private String deviceName; private String rssi; private String deviceByteInfo; private String address; public ScannedData(String deviceName, String rssi, String deviceByteInfo, String address) { this.deviceName = deviceName; this.rssi = rssi; this.deviceByteInfo = deviceByteInfo; this.address = address; } public String getAddress() { return address; } public String getRssi() { return rssi; } public String getDeviceByteInfo() { return deviceByteInfo; } public String getDeviceName() { return deviceName; } @Override public boolean equals(@Nullable Object obj) { ScannedData p = (ScannedData)obj; return this.address.equals(p.address); } @NonNull @Override public String toString() { return this.address; } }
補充一下,
@Override public boolean equals(@Nullable Object obj) { ScannedData p = (ScannedData)obj; return this.address.equals(p.address); } @NonNull @Override public String toString() { return this.address; }
是後面演算法需濾除重複的address用的,先加著吧
相關資料在這邊
Java List去掉重复对象-java8
->https://blog.csdn.net/jiaobuchong/article/details/54412094
3-2 RecyclerView之相關功能RecyclerViewAdapter.java
關於RecyclerView的部分我就不再多詳述,不會的請往這邊->碼農日常-『Android studio』基本RecyclerView用法
RecyclerView要有的功能總共兩項
1.清除資料 clearDevice()
2.加入資料 addDevice(List<ScannedData> arrayList)
以下就是RecyclerViewAdapter.java的內容
class RecyclerViewAdapter extends RecyclerView.Adapter<RecyclerViewAdapter.ViewHolder> { private List<ScannedData> arrayList = new ArrayList<>(); private Activity activity; public RecyclerViewAdapter(Activity activity) { this.activity = activity; } /**清除搜尋到的裝置列表*/ public void clearDevice(){ this.arrayList.clear(); notifyDataSetChanged(); } /**若有不重複的裝置出現,則加入列表中*/ public void addDevice(List<ScannedData> arrayList){ this.arrayList = arrayList; notifyDataSetChanged(); } public class ViewHolder extends RecyclerView.ViewHolder { TextView tvName,tvAddress,tvInfo,tvRssi; public ViewHolder(@NonNull View itemView) { super(itemView); tvName = itemView.findViewById(R.id.textView_DeviceName); tvAddress = itemView.findViewById(R.id.textView_Address); tvInfo = itemView.findViewById(R.id.textView_ScanRecord); tvRssi = itemView.findViewById(R.id.textView_Rssi); } } @NonNull @Override public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.scanned_item,parent,false); return new ViewHolder(view); } @SuppressLint("SetTextI18n") @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { holder.tvName.setText(arrayList.get(position).getDeviceName()); holder.tvAddress.setText("裝置位址:"+arrayList.get(position).getAddress()); holder.tvInfo.setText("裝置挾帶的資訊:\n"+arrayList.get(position).getDeviceByteInfo()); holder.tvRssi.setText("訊號強度:"+arrayList.get(position).getRssi()); } @Override public int getItemCount() { return arrayList.size(); } }
3-3 開啟藍芽掃描相關功能
再給你一次全域變數,比較好閱讀
public class MainActivity extends AppCompatActivity { private static final String TAG = MainActivity.class.getSimpleName()+"My"; BluetoothAdapter mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); private static final int REQUEST_FINE_LOCATION_PERMISSION = 102; private static final int REQUEST_ENABLE_BT = 2; private boolean isScanning = false; ArrayList<ScannedData> findDevice = new ArrayList<>(); RecyclerViewAdapter mAdapter;
首先,要先開啟藍芽的Adapter
如下
/**啟用藍牙適配器*/ final BluetoothManager bluetoothManager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE); mBluetoothAdapter = bluetoothManager.getAdapter();
然後分解一下,執行掃描的指令是這個
mBluetoothAdapter.startLeScan();
然後停止掃描的是這個
mBluetoothAdapter.stopLeScan();
而他們括號內都必須要有個複寫,其覆寫長這樣
mBluetoothAdapter.stopLeScan(new BluetoothAdapter.LeScanCallback() { @Override public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) { } });
為求方便,我把它寫在外面了
mBluetoothAdapter.stopLeScan(mLeScanCallback);
/**顯示掃描到物件*/ private BluetoothAdapter.LeScanCallback mLeScanCallback = new BluetoothAdapter.LeScanCallback() { @Override public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) { } };
大致是這樣,就完成了掃描的雛形了
那麼,這時候我再貼給你全部,應該就可以理解了吧!
public class MainActivity extends AppCompatActivity { private static final String TAG = MainActivity.class.getSimpleName()+"My"; BluetoothAdapter mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); private static final int REQUEST_FINE_LOCATION_PERMISSION = 102; private static final int REQUEST_ENABLE_BT = 2; private boolean isScanning = false; ArrayList<ScannedData> findDevice = new ArrayList<>(); RecyclerViewAdapter mAdapter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); /**權限相關認證*/ checkPermission(); /**初始藍牙掃描及掃描開關之相關功能*/ bluetoothScan(); /**取得欲連線之裝置後跳轉頁面*/ mAdapter.OnItemClick(itemClick); } /**權限相關認證*/ private void checkPermission() { /**確認手機版本是否在API18以上,否則退出程式*/ if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){ /**確認是否已開啟取得手機位置功能以及權限*/ int hasGone = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION); if (hasGone != PackageManager.PERMISSION_GRANTED) { requestPermissions( new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, REQUEST_FINE_LOCATION_PERMISSION); } /**確認手機是否支援藍牙BLE*/ if (!getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { Toast.makeText(this,"Not support Bluetooth", Toast.LENGTH_SHORT).show(); finish(); } /**開啟藍芽適配器*/ if(!mBluetoothAdapter.isEnabled()){ Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); startActivityForResult(enableBtIntent,REQUEST_ENABLE_BT); } }else finish(); } /**初始藍牙掃描及掃描開關之相關功能*/ private void bluetoothScan() { /**啟用藍牙適配器*/ final BluetoothManager bluetoothManager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE); mBluetoothAdapter = bluetoothManager.getAdapter(); /**開始掃描*/ mBluetoothAdapter.startLeScan(mLeScanCallback); isScanning = true; /**設置Recyclerview列表*/ RecyclerView recyclerView = findViewById(R.id.recyclerView_ScannedList); recyclerView.setLayoutManager(new LinearLayoutManager(this)); mAdapter = new RecyclerViewAdapter(this); recyclerView.setAdapter(mAdapter); /**製作停止/開始掃描的按鈕*/ final Button btScan = findViewById(R.id.button_Scan); btScan.setOnClickListener((v)-> { if (isScanning) { /**關閉掃描*/ isScanning = false; btScan.setText("開始掃描"); mBluetoothAdapter.stopLeScan(mLeScanCallback); }else{ /**開啟掃描*/ isScanning = true; btScan.setText("停止掃描"); findDevice.clear(); mBluetoothAdapter.startLeScan(mLeScanCallback); mAdapter.clearDevice(); } }); } @Override protected void onStart() { super.onStart(); final Button btScan = findViewById(R.id.button_Scan); isScanning = true; btScan.setText("停止掃描"); findDevice.clear(); mBluetoothAdapter.startLeScan(mLeScanCallback); mAdapter.clearDevice(); } /**避免跳轉後掃描程序係續浪費效能,因此離開頁面後即停止掃描*/ @Override protected void onStop() { super.onStop(); final Button btScan = findViewById(R.id.button_Scan); /**關閉掃描*/ isScanning = false; btScan.setText("開始掃描"); mBluetoothAdapter.stopLeScan(mLeScanCallback); } /**顯示掃描到物件*/ private BluetoothAdapter.LeScanCallback mLeScanCallback = new BluetoothAdapter.LeScanCallback() { @Override public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) { new Thread(()->{ /**如果裝置沒有名字,就不顯示*/ if (device.getName()!= null){ /**將搜尋到的裝置加入陣列*/ findDevice.add(new ScannedData(device.getName() , String.valueOf(rssi) , byteArrayToHexStr(scanRecord) , device.getAddress())); /**將陣列中重複Address的裝置濾除,並使之成為最新數據*/ ArrayList newList = getSingle(findDevice); runOnUiThread(()->{ /**將陣列送到RecyclerView列表中*/ mAdapter.addDevice(newList); }); } }).start(); } }; /**濾除重複的藍牙裝置(以Address判定)*/ private ArrayList getSingle(ArrayList list) { ArrayList tempList = new ArrayList<>(); try { Iterator it = list.iterator(); while (it.hasNext()) { Object obj = it.next(); if (!tempList.contains(obj)) { tempList.add(obj); } else { tempList.set(getIndex(tempList, obj), obj); } } return tempList; } catch (ConcurrentModificationException e) { return tempList; } } /** * 以Address篩選陣列->抓出該值在陣列的哪處 */ private int getIndex(ArrayList temp, Object obj) { for (int i = 0; i < temp.size(); i++) { if (temp.get(i).toString().contains(obj.toString())) { return i; } } return -1; } /** * Byte轉16進字串工具 */ public static String byteArrayToHexStr(byte[] byteArray) { if (byteArray == null) { return null; } StringBuilder hex = new StringBuilder(byteArray.length * 2); for (byte aData : byteArray) { hex.append(String.format("%02X", aData)); } String gethex = hex.toString(); return gethex; } /**取得欲連線之裝置後跳轉頁面*/ private RecyclerViewAdapter.OnItemClick itemClick = new RecyclerViewAdapter.OnItemClick() { @Override public void onItemClick(ScannedData selectedDevice) { Intent intent = new Intent(MainActivity.this, DeviceInfoActivity.class); intent.putExtra(DeviceInfoActivity.INTENT_KEY,selectedDevice); startActivity(intent); } }; }
其中
/**濾除重複的藍牙裝置(以Address判定)*/ private ArrayList getSingle(ArrayList list) { ArrayList tempList = new ArrayList<>(); try { Iterator it = list.iterator(); while (it.hasNext()) { Object obj = it.next(); if (!tempList.contains(obj)) { tempList.add(obj); } else { tempList.set(getIndex(tempList, obj), obj); } } return tempList; } catch (ConcurrentModificationException e) { return tempList; } } /** * 以Address篩選陣列->抓出該值在陣列的哪處 */ private int getIndex(ArrayList temp, Object obj) { for (int i = 0; i < temp.size(); i++) { if (temp.get(i).toString().contains(obj.toString())) { return i; } } return -1; }
都是用來濾資料的,輸入一團資料後,會整理出不重複的資料
若資料已存在,則會更新該筆資料的內容
其判斷資料重複依據則是使用裝置的address來斷定
最後
/** * Byte轉16進字串工具 */ public static String byteArrayToHexStr(byte[] byteArray) { if (byteArray == null) { return null; } StringBuilder hex = new StringBuilder(byteArray.length * 2); for (byte aData : byteArray) { hex.append(String.format("%02X", aData)); } String gethex = hex.toString(); return gethex; }
的部分
則是用來將藍芽裝置所夾帶的資料化為字串用的,供餐考
寫到這邊就可以執行看看囉!沒問題的話應該就可以掃到裝置了
而且資料都會有動態變化喔!試試看吧(・∀・)
結語
其實藍芽功能早在半年前就想寫了...但是一直沒有什麼動力寫(´υ`)
因為我程度也沒有說很好,而且藍芽部分常常都覺得自己一知半解
現在會寫出來也不是因為我完全理解了,而是我覺得目前寫出來應該沒什麼大礙....還有就是因為放連假XD
我覺得寫一個完整的藍芽很不簡單,因為每個藍芽裝置的內容都不太一樣
雖然到目前為止還沒出現不一樣的地方,但是等下篇就會知道了
下篇的文章我會在下禮拜寫,但是Github已經連同下週的也寫好了,想直接參考程式碼的就去看Git吧
更新:
本篇完成的話,請移步到下一篇吧 :D
留言列表