设备详情与设置界面
设备详情与设置界面是 ATTConnect 示例应用中用于展示已连接蓝牙设备信息、执行 GATT 数据收发调试、以及管理本地日志文件的一组界面,包含 DeviceDetailsFragment/DeviceDetailsViewModel 与 LogFileFragment 等实现。
目的与范围
本页面向开发者完整讲解「设备详情与设置界面」这一能力边界内的全部实现:
- 设备详情界面(
DeviceDetailsFragment+DeviceDetailsViewModel):展示已连接设备名称、打印 GATT 服务/特征信息、发送自定义十六进制数据、实时显示 BLE 收发日志。 - 设置类界面(
LogFileFragment):管理 SDK 生成的本地日志文件,支持打开、下载、分享、删除与一键清空。
以下内容属于兄弟页面,不在本页展开:
- 设备的扫描、连接、断开与设备列表管理(
DeviceFragment/DeviceViewModel/ScanDeviceAdapter/ConnectedDeviceAdapter),请参见「设备列表与扫描界面」。 - 应用主页与通用容器(
HomeActivity/CommonActivity/BasicFragment),请参见「通用 UI 框架与主页」。 - BLE 底层封装(
BleManager/BleEventCallback/SendBleDataThread),请参见「BLE 管理器与数据通道」。
概述
ATTConnect 是杰理科技(Jieli-Tech)提供的 Android 蓝牙 ATT 示例工程。示例的核心调试路径是:扫描 → 连接 → 进入设备详情 → 查看 GATT 服务信息 → 手动收发数据 → 查看收发日志。设备详情界面正是这条路径的终点,它把 BleManager 提供的异步收发能力以最直观的「日志流」形式呈现给开发者,方便验证协议与排查问题。
与此同时,SDK 的 JL_Log 组件会持续产生日志文件,LogFileFragment 让开发者可以在手机上直接浏览、导出或清理这些文件,无需连接电脑抓取日志。
两个界面都基于项目统一的 MVVM + ViewBinding 架构:BasicFragment 提供生命周期与权限回调骨架,CommonActivity 作为通用宿主承载 Fragment,ViewModel 通过 BleManager 单例与底层 BLE 引擎通信。
架构
flowchart TD
subgraph sg_Entry["入口层"]
DeviceFrag["DeviceFragment<br/>(设备列表)"]
end
subgraph sg_Host["通用宿主"]
CommonActivity["CommonActivity<br/>startCommonActivity"]
end
subgraph sg_Details["设备详情界面"]
DetailsFrag["DeviceDetailsFragment"]
DetailsVM["DeviceDetailsViewModel<br/>extends BluetoothViewModel"]
end
subgraph sg_Settings["设置界面"]
LogFrag["LogFileFragment"]
LogVM["LogFileViewModel"]
LogAdapter["LogFileAdapter<br/>SwipeMenuLayout 侧滑菜单"]
end
subgraph sg_Ble["BLE 基础设施"]
BleManager["BleManager(单例)"]
Callback["BleEventCallback"]
Config["Config<br/>BLE_SERVICE_UUID / BLE_WRITE_UUID"]
JL_Log["JL_Log(日志文件)"]
end
DeviceFrag -->|"DeviceDetailsFragment.startActivity()"| CommonActivity
CommonActivity --> DetailsFrag
DetailsFrag -->|"ViewModelProvider + Factory(device)"| DetailsVM
DetailsVM -->|"registerBleEventCallback"| BleManager
BleManager -->|"onBleDataNotification"| DetailsVM
DetailsVM -->|"writeDataByBleAsync(Config.BLE_*_UUID)"| BleManager
DetailsVM -->|"logMLD 观察"| DetailsFrag
DetailsFrag -->|"从设备列表进入设置"| LogFrag
LogFrag -->|"readLogFiles / clearLog / deleteFile"| LogVM
LogFrag --> LogAdapter
LogAdapter -->|"读取/清理"| JL_Log
架构说明:
- 入口层:
DeviceFragment(设备管理页)是详情界面的唯一入口,点击已连接设备时调用DeviceDetailsFragment.startActivity(context, device),将BluetoothDevice通过 Bundle 传入通用宿主CommonActivity。 - 宿主层:
CommonActivity.startCommonActivity根据 Fragment 的 canonicalName 动态创建 Fragment 实例,实现「一个 Activity 承载多个页面」的轻量导航。 - 设备详情层:
DeviceDetailsFragment只负责 UI 与交互,业务逻辑全部委托给DeviceDetailsViewModel;ViewModel 通过继承BluetoothViewModel获得bleManager引用,注册BleEventCallback接收通知,并通过logMLD(MutableLiveData<String>)把日志推回 UI。 - 设置层:
LogFileFragment+LogFileViewModel+LogFileAdapter构成日志文件管理链路,直接操作JL_Log落盘的本地文件。 - BLE 基础设施:
BleManager是全局单例,承载扫描、连接、读写与事件回调分发;Config提供固定 UUID(服务、写特征),是调试数据通道的协议常量。
设备详情界面实现
界面入口与参数传递
DeviceDetailsFragment 通过伴生对象提供静态入口方法,这是整个详情页的「唯一受控入口」:
companion object {
const val KEY_DEVICE = "device"
fun startActivity(context: Context, device: BluetoothDevice) {
CommonActivity.startCommonActivity(
context, DeviceDetailsFragment::class.java.canonicalName,
bundleOf(Pair<String, Parcelable>(KEY_DEVICE, device))
)
}
}
设计意图:BluetoothDevice 实现了 Parcelable,可以直接塞进 Bundle 随 Activity 传递;用 canonicalName 而非显式 Intent 跳转,是为了复用 CommonActivity 这一通用容器,避免为每个页面新建 Activity。
在 onViewCreated 中,Fragment 从参数中取出设备,若为空则立即 finish(0) 关闭页面——这是一种防御性校验,防止外部以错误参数唤起页面导致空指针:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val device = arguments?.getParcelable(KEY_DEVICE) as BluetoothDevice?
if (null == device) {
finish(0)
return
}
viewModel = ViewModelProvider(
this,
DeviceDetailsViewModel.Factory(device)
)[DeviceDetailsViewModel::class.java]
initUI()
addObserver()
viewModel.printDeviceInfo()
}
注意 ViewModel 是通过自定义 Factory(device) 创建的:DeviceDetailsViewModel 的构造函数需要 BluetoothDevice 参数,默认的无参 ViewModelProvider 无法满足,因此必须提供工厂。创建完成后依次执行 initUI()(界面装配)、addObserver()(数据订阅)、printDeviceInfo()(立即打印一次 GATT 服务信息)。
界面初始化(initUI)
private fun initUI() {
binding.viewTopBar.tvTitle.text = BluetoothUtil.getDeviceName(viewModel.device)
binding.viewTopBar.btnLeft.setOnClickListener {
finish(0)
}
binding.btnClearLog.setOnClickListener {
addLog("", false)
}
binding.btnClearData.setOnClickListener {
binding.etData.setText("")
binding.etData.setSelection(0)
}
binding.btnSendData.setOnClickListener {
tryToSendData()
}
binding.tvLogcat.isLongClickable = false
binding.tvLogcat.movementMethod = ScrollingMovementMethod.getInstance()
}
界面元素一览:
| 控件 | 作用 | 行为 |
|---|---|---|
viewTopBar.tvTitle | 顶部标题 | 显示 BluetoothUtil.getDeviceName(device)(优先取友好名称) |
viewTopBar.btnLeft | 返回键 | finish(0) 关闭页面 |
btnClearLog | 清空日志 | 以「非追加」模式重设日志文本(addLog("", false)) |
btnClearData | 清空输入 | 清空十六进制输入框并重置光标位置 |
btnSendData | 发送数据 | 触发 tryToSendData() |
tvLogcat | 日志展示区 | 禁用长按,设置 ScrollingMovementMethod 支持滚动 |
设计意图:输入框与日志区分离,开发者先在 etData 输入十六进制串,点击发送后日志区实时回显「发送了哪些字节、从哪个特征发出、结果如何」,形成闭环调试体验。
数据观察(addObserver)
private fun addObserver() {
viewModel.btStateMLD.observe(viewLifecycleOwner) {
if (!it) {
finish(0)
}
}
viewModel.deviceStateMLD.observe(viewLifecycleOwner) {
if (!BluetoothUtil.deviceEquals(it.device, viewModel.device)) return@observe
if (it.state != BluetoothProfile.STATE_CONNECTED) {
finish(0)
}
}
viewModel.logMLD.observeForever(logObserver)
}
三个观察者的职责不同:
btStateMLD:全局蓝牙开关状态。蓝牙一旦关闭,页面失去意义,直接关闭。deviceStateMLD:设备连接状态。先通过BluetoothUtil.deviceEquals校验事件来源设备是否就是本页设备(过滤其他设备的连接事件),再判断状态是否为STATE_CONNECTED;断开即关闭页面。这是「跟随连接生命周期」的关键设计——设备断开时详情页自动退出,避免对已断开的 GATT 继续操作。logMLD:使用observeForever(永久观察)而非observe(viewLifecycleOwner),并在onDestroyView中手动移除:
override fun onDestroyView() {
viewModel.logMLD.removeObserver(logObserver)
super.onDestroyView()
}
为什么用 observeForever?因为 BLE 通知可能在任何生命周期状态下到达(例如页面进入后台时仍希望继续累积日志),observeForever 不会因 ON_START 暂停而丢弃事件;代价是必须在 onDestroyView 对称地 removeObserver,否则会泄漏对 Fragment 的引用。这是一对必须成对出现的 API。
发送数据流程(tryToSendData)
private fun tryToSendData() {
val text = binding.etData.text.toString().trim()
if (text.isEmpty()) {
showTips(getString(R.string.send_data_empty_tips))
return
}
viewModel.sendData(CHexConver.hexStr2Bytes(text))
}
输入校验规则:去除首尾空白后若为空,弹出 send_data_empty_tips 提示并返回。合法输入经 CHexConver.hexStr2Bytes(十六进制字符串 → 字节数组)转换后交给 ViewModel 异步发送。注意转换失败(非法十六进制字符)的场景由 CHexConver 内部容错处理,Fragment 层只做空值校验。
日志显示机制(addLog)
private fun addLog(text: String, isAppend: Boolean = true) {
if (!isAppend) {
binding.tvLogcat.text = text
binding.tvLogcat.scrollTo(0, 0)
} else {
if (binding.tvLogcat.text.length > 4 * 1024 * 1024) {
binding.tvLogcat.text = text
binding.tvLogcat.scrollTo(0, 0)
}
binding.tvLogcat.append(text)
binding.tvLogcat.append("\n")
val offset: Int = ViewUtil.getTextViewHeight(binding.tvLogcat)
if (offset > binding.tvLogcat.height) {
binding.tvLogcat.scrollTo(0, offset - binding.tvLogcat.height)
}
}
}
这是设备详情页性能设计的核心:TextView.append 在文本量极大时会出现卡顿与内存膨胀,因此这里设置了一个 4MB(4 × 1024 × 1024 字符)的软上限——超过后直接整体替换为新日志,防止长时间调试导致 OOM。同时每次追加后用 ViewUtil.getTextViewHeight 计算内容真实高度,超出一屏时自动滚动到底部(scrollTo 到内容与可视区的高度差),保证最新日志始终可见。
设备详情 ViewModel 实现
DeviceDetailsViewModel 继承自 BluetoothViewModel,构造函数直接接收 BluetoothDevice:
class DeviceDetailsViewModel(val device: BluetoothDevice) : BluetoothViewModel() {
companion object {
private val dataFormat = SimpleDateFormat("yyyy/MM/dd HH:mm:ss.SSS", Locale.ENGLISH)
}
val logMLD = MutableLiveData<String>()
BluetoothViewModel 基类持有全局 BleManager 引用,因此 ViewModel 可以直接注册 BLE 回调、调用读写 API,无需自己管理单例获取。
BLE 事件回调注册
private val btCallback = object : BleEventCallback() {
override fun onBleDataNotification(
device: BluetoothDevice?,
serviceUuid: UUID?,
characteristicsUuid: UUID?,
data: ByteArray?
) {
if (!BluetoothUtil.deviceEquals(device, this@DeviceDetailsViewModel.device)) return
logMsg(
"[Received Data] : (${UuidUtil.read16BitUUID(serviceUuid!!)}) " +
": (${UuidUtil.read16BitUUID(characteristicsUuid!!)}) <--- \n${
CHexConver.byte2HexStr(
data
)
}"
)
}
}
init {
bleManager.registerBleEventCallback(btCallback)
}
override fun onCleared() {
bleManager.unregisterBleEventCallback(btCallback)
super.onCleared()
}
设计要点:
- 设备过滤:回调第一行即校验事件来源设备,非本页设备直接丢弃——因为
BleEventCallback是全局注册的,所有设备的通知都会进入这里。 - UUID 压缩展示:
UuidUtil.read16BitUUID把 128 位 UUID 截取为 16 位可读短格式(如0000FFE1-0000-1000-8000-00805F9B34FB→FFE1),日志更易读。 - 成对注册/注销:
init注册、onCleared注销,生命周期由 ViewModel 托管,避免 Activity 重建造成回调累积。
打印设备信息(printDeviceInfo)
fun printDeviceInfo() {
if (bleManager.isConnectedDevice(device)) {
bleManager.getConnectedBtGatt(device)?.let { gatt ->
logMsg(
BluetoothUtil.printBleGattServices(
getContext(),
device,
gatt,
BluetoothGatt.GATT_SUCCESS
)
)
}
}
}
页面创建后立即调用一次:若设备仍处于连接状态,从 BleManager 取回缓存的 BluetoothGatt,通过 BluetoothUtil.printBleGattServices 枚举全部 Service / Characteristic / Descriptor 并格式化为日志文本,开发者进入页面即可看到完整的 GATT 结构,方便对照协议核对特征 UUID。
发送数据(sendData)
fun sendData(data: ByteArray) {
bleManager.writeDataByBleAsync(
device, Config.BLE_SERVICE_UUID, Config.BLE_WRITE_UUID, data
) { _, serviceUUID, characteristicUUID, result, value ->
logMsg(
"[Send data] : (${UuidUtil.read16BitUUID(serviceUUID)}) " +
": (${UuidUtil.read16BitUUID(characteristicUUID)}) | $result ---> \n${
CHexConver.byte2HexStr(
value
)
}"
)
}
}
写入目标固定为 Config.BLE_SERVICE_UUID / Config.BLE_WRITE_UUID——这是 ATTConnect 协议约定的调试服务与写特征。writeDataByBleAsync 是异步接口,底层由 SendBleDataThread 排队写入,回调携带 result(写结果码)与 value(实际写入的字节),ViewModel 将「发出方向、特征、结果、内容」一并记入日志,开发者可据此判断写入是否成功。
线程安全的日志推送(logMsg)
private fun logMsg(content: String) {
JL_Log.d(tag, "logMsg", content)
val msg = BluetoothUtil.formatString(
"%s %s",
dataFormat.format(Calendar.getInstance().time),
content
)
if (Thread.currentThread().id == Looper.getMainLooper().thread.id) {
logMLD.value = msg
} else {
logMLD.postValue(msg)
}
}
BleEventCallback 的调用线程来自 BLE 引擎的工作线程,而 MutableLiveData.setValue 只能在主线程调用。这里显式判断当前线程:主线程用 value(同步立即生效),后台线程用 postValue(切到主线程分发)。这是 LiveData 跨线程更新的标准防御写法。每条日志统一带上 yyyy/MM/dd HH:mm:ss.SSS 毫秒级时间戳,且同步输出到 JL_Log 落盘,保证屏幕日志与文件日志一致。
ViewModel 工厂(Factory)
@Suppress("UNCHECKED_CAST")
class Factory(private val device: BluetoothDevice) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return DeviceDetailsViewModel(device) as T
}
}
由于 ViewModel 构造函数需要 BluetoothDevice 参数,标准的 ViewModelProvider 默认工厂无法实例化,因此实现 ViewModelProvider.Factory 并把设备引用传入。这样 Fragment 旋转重建后,ViewModelProvider 会复用同一 ViewModel 实例(含已注册的 BLE 回调),而设备引用始终来自首次创建时捕获的对象。
日志文件设置界面(LogFileFragment)
LogFileFragment 是「设置」能力中的日志管理页面,展示 JL_Log 生成的日志文件列表,支持打开、下载、分享、删除与清空。它同样基于 BasicFragment 宿主于 CommonActivity。
@RuntimePermissions
class LogFileFragment : BasicFragment() {
companion object {
fun newInstance() = LogFileFragment()
}
private lateinit var binding: FragmentLogFileBinding
private lateinit var viewModel: LogFileViewModel
private lateinit var adapter: LogFileAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(this)[LogFileViewModel::class]
initUI()
addObserver()
viewModel.readLogFiles()
}
与设备详情不同,LogFileViewModel 无构造参数,直接 ViewModelProvider(this) 即可。页面创建后立即调用 viewModel.readLogFiles() 异步扫描日志目录。
存储权限处理
访问外部存储需要运行时权限,Fragment 使用 PermissionsDispatcher 注解框架(@RuntimePermissions)声明式管理权限:
@NeedsPermission(value = [Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE])
fun readStoragePermissionAllow(filePath: String) {
val hasReadPermission = PermissionUtil.hasReadStoragePermission(requireContext())
val hasWritePermission = PermissionUtil.hasWriteStoragePermission(requireContext())
JL_Log.d(
TAG, "readStoragePermissionAllow", "hasReadPermission : $hasReadPermission, " +
"hasWritePermission : $hasWritePermission"
)
dismissPermissionTipsDialog()
copyFileToDownloadFolder(filePath)
}
@OnShowRationale(value = [Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE])
fun showRelationFromStoragePermission(request: PermissionRequest) {
request.proceed()
}
@OnPermissionDenied(value = [Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE])
fun onDeniedFromStoragePermission() {
dismissPermissionTipsDialog()
showTips(getString(R.string.missing_permission_desc))
}
三个注解方法的职责分工:
@NeedsPermission:权限已授予时的实际业务逻辑——下载日志文件到 Download 目录;@OnShowRationale:系统要求解释时直接request.proceed()继续请求(示例应用从简处理);@OnPermissionDenied:被拒绝时关闭提示对话框并弹出missing_permission_desc提示。
此外还重写了 onRequestPermissionsResult,把结果转交给 PermissionsDispatcher 生成的处理逻辑(onRequestPermissionsResult(requestCode, grantResults) 扩展函数)。BasicFragment 基类中的 permissionHandler 机制(见 DeviceFragment 的用法)负责统一处理权限请求回调。
工具栏与列表装配(initUI)
private fun initUI() {
binding.viewToolBar.tvTitle.text = getString(R.string.log_file)
binding.viewToolBar.btnLeft.setOnClickListener { finish(100) }
binding.viewToolBar.btnRight.setImageResource(R.drawable.ic_cleaning_black)
binding.viewToolBar.btnRight.setOnClickListener {
viewModel.clearLog()
}
adapter = LogFileAdapter()
adapter.setOnItemClickListener { adapter, _, position ->
val file = (adapter as LogFileAdapter).getItem(position)
tryToOpenFile(file.path)
}
adapter.setOnItemChildClickListener { adapter, view, position ->
val file = (adapter as LogFileAdapter).getItem(position)
when (view.id) {
R.id.btn_download -> {
if (FileUtil.isFileInDownload(requireContext(), file.name)) {
return@setOnItemChildClickListener
}
tryToDownloadFile(file.path)
}
R.id.btn_share -> {
tryToShareFile(file.path)
}
R.id.btn_remove -> {
viewModel.deleteFile(file.path)
adapter.getViewByPosition(position, R.id.main)?.let { itemView ->
if (itemView is SwipeMenuLayout) {
itemView.quickClose()
}
}
}
}
}
binding.rvLogFile.layoutManager = LinearLayoutManager(requireContext())
binding.rvLogFile.adapter = adapter
binding.rvLogFile.addItemDecoration(
CommonItemDecoration(
requireContext(),
ContextCompat.getColor(requireContext(), R.color.line_color),
RecyclerView.VERTICAL, ViewUtil.dp2px(requireContext(), 1)
)
)
binding.viewToolBar.btnRight.hide()
}
关键行为:
- 右侧按钮是「清空日志」(
ic_cleaning_black图标),点击后调用viewModel.clearLog();初始隐藏(btnRight.hide()),由addObserver在检测到日志文件时再显示,避免空状态下出现无意义的按钮。 - 列表行点击:打开日志文件内容(
tryToOpenFile),方便直接查看协议日志文本。 - 行内子按钮:基于
SwipeMenuLayout(第三方侧滑菜单库com.mcxtzhang.swipemenulib)提供三个操作:btn_download:先经FileUtil.isFileInDownload去重(已存在于 Download 目录则跳过),再走tryToDownloadFile→ 权限请求 →copyFileToDownloadFolder;btn_share:tryToShareFile调用系统分享;btn_remove:viewModel.deleteFile删除文件,并quickClose()收回侧滑菜单。
- 列表样式:
LinearLayoutManager+CommonItemDecoration(1dp 分隔线),视觉与项目其他列表一致。
设置界面数据流
flowchart LR
A["LogFileFragment 创建"] --> B["viewModel.readLogFiles()"]
B --> C["LogFileViewModel 扫描目录"]
C --> D["LogFileAdapter 刷新列表"]
D --> E{"用户操作"}
E -->|"行点击"| F["tryToOpenFile 查看内容"]
E -->|"btn_download"| G["权限检查 → copyFileToDownloadFolder"]
E -->|"btn_share"| H["系统分享 Intent"]
E -->|"btn_remove"| I["viewModel.deleteFile + quickClose"]
E -->|"btnRight 清空"| J["viewModel.clearLog()"]
核心流程
设备详情页完整时序
从设备列表点击已连接设备到页面显示 GATT 信息与收发日志的完整时序如下:
sequenceDiagram
participant U as 用户
participant DF as DeviceFragment
participant CA as CommonActivity
participant FR as DeviceDetailsFragment
participant VM as DeviceDetailsViewModel
participant BM as BleManager
participant DEV as 蓝牙设备
U->>DF: 点击已连接设备
DF->>CA: DeviceDetailsFragment.startActivity(device)
CA->>FR: 创建 Fragment(Bundle 携带 device)
FR->>FR: 校验 device 非空(空则 finish)
FR->>VM: ViewModelProvider + Factory(device)
VM->>BM: registerBleEventCallback(btCallback)
FR->>FR: initUI() 装配控件
FR->>FR: addObserver() 订阅状态
FR->>VM: printDeviceInfo()
VM->>BM: isConnectedDevice / getConnectedBtGatt
BM-->>VM: BluetoothGatt
VM-->>FR: logMLD.value(printBleGattServices)
FR->>FR: tvLogcat 显示 GATT 结构
U->>FR: 输入十六进制并点击发送
FR->>VM: sendData(hexStr2Bytes(text))
VM->>BM: writeDataByBleAsync(SERVICE/WRITE UUID)
BM->>DEV: 写入特征
DEV-->>BM: 数据通知
BM-->>VM: onBleDataNotification(device, uuid, data)
VM-->>FR: logMLD(时间戳 + 收发内容)
FR->>FR: addLog 追加 + 自动滚动
关键状态流转
stateDiagram-v2
[*] --> 详情页显示: device 非空且已连接
详情页显示 --> 自动关闭: 蓝牙关闭(btStateMLD=false)
详情页显示 --> 自动关闭: 设备断开(STATE != CONNECTED)
详情页显示 --> 发送中: 点击发送且输入非空
发送中 --> 详情页显示: 写入回调完成并记日志
详情页显示 --> [*]: 用户返回(finish)
页面设计为「跟随连接生命周期」:蓝牙关闭或设备断开时自动 finish(0),保证用户不会停留在已失效的调试页面上继续操作;这是示例工程对连接状态强一致性的体现。
使用示例
从设备列表进入详情页
fun startActivity(context: Context, device: BluetoothDevice) {
CommonActivity.startCommonActivity(
context, DeviceDetailsFragment::class.java.canonicalName,
bundleOf(Pair<String, Parcelable>(KEY_DEVICE, device))
)
}
发送十六进制数据并记录结果
fun sendData(data: ByteArray) {
bleManager.writeDataByBleAsync(
device, Config.BLE_SERVICE_UUID, Config.BLE_WRITE_UUID, data
) { _, serviceUUID, characteristicUUID, result, value ->
logMsg(
"[Send data] : (${UuidUtil.read16BitUUID(serviceUUID)}) " +
": (${UuidUtil.read16BitUUID(characteristicUUID)}) | $result ---> \n${
CHexConver.byte2HexStr(
value
)
}"
)
}
}
接收 BLE 通知并回显到日志
override fun onBleDataNotification(
device: BluetoothDevice?,
serviceUuid: UUID?,
characteristicsUuid: UUID?,
data: ByteArray?
) {
if (!BluetoothUtil.deviceEquals(device, this@DeviceDetailsViewModel.device)) return
logMsg(
"[Received Data] : (${UuidUtil.read16BitUUID(serviceUuid!!)}) " +
": (${UuidUtil.read16BitUUID(characteristicsUuid!!)}) <--- \n${
CHexConver.byte2HexStr(
data
)
}"
)
}
日志文件列表的侧滑操作
adapter.setOnItemChildClickListener { adapter, view, position ->
val file = (adapter as LogFileAdapter).getItem(position)
when (view.id) {
R.id.btn_download -> {
if (FileUtil.isFileInDownload(requireContext(), file.name)) {
return@setOnItemChildClickListener
}
tryToDownloadFile(file.path)
}
R.id.btn_share -> tryToShareFile(file.path)
R.id.btn_remove -> {
viewModel.deleteFile(file.path)
adapter.getViewByPosition(position, R.id.main)?.let { itemView ->
if (itemView is SwipeMenuLayout) {
itemView.quickClose()
}
}
}
}
}
配置选项
本能力涉及的常量与配置均定义在对应文件的伴生对象或 Config 数据类中:
| 常量 | 类型 | 默认值/取值 | 定义位置 | 说明 |
|---|---|---|---|---|
KEY_DEVICE | String | "device" | DeviceDetailsFragment | 传递 BluetoothDevice 的 Bundle key |
Config.BLE_SERVICE_UUID | String | 协议固定值 | data.constant.Config | 调试用 GATT 服务 UUID(发送目标) |
Config.BLE_WRITE_UUID | String | 协议固定值 | data.constant.Config | 调试用写特征 UUID(发送目标) |
| 日志文本上限 | Int | 4 * 1024 * 1024 字符 | DeviceDetailsFragment.addLog | 超过后整体替换日志,防内存膨胀 |
| 时间戳格式 | String | yyyy/MM/dd HH:mm:ss.SSS | DeviceDetailsViewModel | 每条日志前缀的毫秒级时间格式 |
READ/WRITE_EXTERNAL_STORAGE | 权限 | 运行时申请 | LogFileFragment | 日志文件下载所需存储权限 |
注:
Config.BLE_SERVICE_UUID/Config.BLE_WRITE_UUID的具体值定义在 Config.kt 中,属于协议层常量,本页不重复列出。
API 参考
DeviceDetailsFragment.startActivity(context: Context, device: BluetoothDevice)
静态入口方法,从任意 Context 唤起设备详情页。
参数:
context(Context):调用方上下文device(BluetoothDevice):目标设备,作为 Parcelable 传入 Bundle
行为: 经 CommonActivity.startCommonActivity 以 canonicalName 动态创建 Fragment 并携带 KEY_DEVICE 参数。
DeviceDetailsViewModel.sendData(data: ByteArray)
向 Config.BLE_SERVICE_UUID / Config.BLE_WRITE_UUID 异步写入数据。
参数:
data(ByteArray):待写入的字节(由十六进制字符串转换而来)
回调: writeDataByBleAsync 的完成回调携带 (device, serviceUUID, characteristicUUID, result, value),ViewModel 将其格式化为日志;result 为写入结果码,value 为实际写入字节。
DeviceDetailsViewModel.printDeviceInfo()
打印当前设备的 GATT 服务结构。
行为: 仅当 bleManager.isConnectedDevice(device) 为真时,取回 BluetoothGatt 并调用 BluetoothUtil.printBleGattServices,将全部 Service/Characteristic 信息写入日志;未连接时静默无输出。
DeviceDetailsViewModel.Factory(device: BluetoothDevice)
带参 ViewModel 工厂,实现 ViewModelProvider.Factory。
参数:
device(BluetoothDevice):注入 ViewModel 构造函数的设备引用
返回: 类型安全的 DeviceDetailsViewModel 实例。
故障模式、边界情况与并发
页面自动关闭策略
| 触发条件 | 观察源 | 行为 |
|---|---|---|
Bundle 中 device 为空 | onViewCreated | finish(0),防御性校验 |
| 蓝牙全局关闭 | btStateMLD = false | finish(0) |
目标设备断开(非 STATE_CONNECTED) | deviceStateMLD | finish(0)(先经 deviceEquals 过滤其他设备事件) |
| 输入为空点击发送 | tryToSendData | 弹出 send_data_empty_tips,不发送 |
边界与内存保护
- 日志量上限:
addLog在文本超过 4MB 时整体替换并滚动回顶部,防止长时调试导致TextView内存膨胀或 ANR; - observeForever 泄漏风险:
logMLD使用永久观察,必须在onDestroyView对称removeObserver;漏掉任何一侧都会造成观察者泄漏或日志停止刷新; - 回调线程安全:
logMsg区分主线程与后台线程,分别使用setValue/postValue,避免MutableLiveData的IllegalStateException(不能在非主线程 setValue); - 其他设备事件干扰:所有 BLE 回调入口先做
BluetoothUtil.deviceEquals过滤,防止多设备场景下日志串台。
日志文件操作的失败路径
- 存储权限被拒:
@OnPermissionDenied关闭提示框并弹出missing_permission_desc; - 重复下载:
FileUtil.isFileInDownload已存在同名文件时直接跳过下载; - 文件删除:
btn_remove先删除再quickClose()收回侧滑菜单,避免残留 UI 状态;删除失败由LogFileViewModel.deleteFile内部容错。
性能与运行注意事项
- 日志追加采用增量
append+ 高度计算滚动:相比一次性重设全文,能承受长时间调试产生的大量日志;4MB 软上限是内存与可读性的折中。 printDeviceInfo仅执行一次:在onViewCreated调用,避免每次 GATT 发现回调重复打印。- 写入走异步队列:
writeDataByBleAsync由SendBleDataThread串行排队,UI 线程不阻塞;回调结果再切回日志线程。 JL_Log.d同步落盘:每条屏幕日志同步写文件,若需长期采集可在LogFileFragment中下载全部文件,无需连接电脑。
扩展点
- 协议常量替换:
Config.BLE_SERVICE_UUID/Config.BLE_WRITE_UUID是数据通道的唯一协议约束,接入自有协议时替换这两个常量即可,其余逻辑(发送、日志、回调)无需改动。 - BleEventCallback 扩展:
DeviceDetailsViewModel当前只覆写onBleDataNotification;如需展示 MTU、连接参数或 GATT 状态变化,可在btCallback中继续覆写BleEventCallback的其他方法(由 BleEventCallbackManager.java 统一分发)。 - 日志操作扩展:
LogFileAdapter的行内按钮由when (view.id)分发,新增操作(如重命名、批量导出)只需在 item 布局添加控件并在setOnItemChildClickListener增加分支。 - 页面导航复用:
CommonActivity.startCommonActivity支持任意BasicFragment子类,新增设置页只需仿照LogFileFragment.newInstance()模式。
测试情况
示例工程为 Android 演示项目,UI 层未发现针对详情页/设置页的专用单元测试;自动生成的示例测试位于 ATTConnect/app/src/androidTest/.../ExampleInstrumentedTest.kt。建议的验证路径为真机手动流程:扫描连接设备 → 进入详情查看 GATT 结构 → 发送十六进制数据观察回显 → 断开设备确认页面自动退出 → 日志页下载/分享/删除文件。BLE 底层收发逻辑的单元测试覆盖情况请参见「BLE 管理器与数据通道」页面。
相关链接
- 设备列表与扫描界面 ——
DeviceFragment/DeviceViewModel的扫描、连接与列表管理 - 通用 UI 框架与主页 ——
CommonActivity/BasicFragment/HomeActivity的导航与生命周期骨架 - BLE 管理器与数据通道 ——
BleManager/BleEventCallback/SendBleDataThread的底层实现 - 核心源码: