杰理 SDK 文档中心
首页
首页
  • 项目概览

    • 仓库简介与示例构成
    • 支持的平台与协议
  • 快速开始

    • 导入工程与编译运行
    • 蓝牙权限配置
  • 应用架构

    • 工程结构与模块分层
    • 核心类与回调接口
  • 核心功能

    • 蓝牙设备扫描
    • ATT 设备连接与断开管理
    • 数据收发与通知回调
  • 配置与调试

    • 协议配置常量
    • 日志系统与调试指南
  • 界面与交互

    • 设备扫描与连接界面
    • 设备详情与设置界面

设备详情与设置界面

设备详情与设置界面是 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))
        )
    }
}

来源:DeviceDetailsFragment.kt

设计意图: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()
}

来源:DeviceDetailsFragment.kt

注意 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()
}

来源:DeviceDetailsFragment.kt

界面元素一览:

控件作用行为
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)
}

来源:DeviceDetailsFragment.kt

三个观察者的职责不同:

  1. btStateMLD:全局蓝牙开关状态。蓝牙一旦关闭,页面失去意义,直接关闭。
  2. deviceStateMLD:设备连接状态。先通过 BluetoothUtil.deviceEquals 校验事件来源设备是否就是本页设备(过滤其他设备的连接事件),再判断状态是否为 STATE_CONNECTED;断开即关闭页面。这是「跟随连接生命周期」的关键设计——设备断开时详情页自动退出,避免对已断开的 GATT 继续操作。
  3. logMLD:使用 observeForever(永久观察)而非 observe(viewLifecycleOwner),并在 onDestroyView 中手动移除:
override fun onDestroyView() {
    viewModel.logMLD.removeObserver(logObserver)
    super.onDestroyView()
}

来源:DeviceDetailsFragment.kt

为什么用 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))
}

来源:DeviceDetailsFragment.kt

输入校验规则:去除首尾空白后若为空,弹出 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)
        }
    }
}

来源:DeviceDetailsFragment.kt

这是设备详情页性能设计的核心: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>()

来源:DeviceDetailsViewModel.kt

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()
}

来源:DeviceDetailsViewModel.kt

设计要点:

  • 设备过滤:回调第一行即校验事件来源设备,非本页设备直接丢弃——因为 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
                )
            )
        }
    }
}

来源:DeviceDetailsViewModel.kt

页面创建后立即调用一次:若设备仍处于连接状态,从 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
                        )
                    }"
        )
    }
}

来源:DeviceDetailsViewModel.kt

写入目标固定为 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)
    }
}

来源:DeviceDetailsViewModel.kt

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
    }
}

来源:DeviceDetailsViewModel.kt

由于 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()
    }

来源:LogFileFragment.kt

与设备详情不同,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))
}

来源:LogFileFragment.kt

三个注解方法的职责分工:

  • @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()
}

来源:LogFileFragment.kt

关键行为:

  • 右侧按钮是「清空日志」(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))
    )
}

来源:DeviceDetailsFragment.kt

发送十六进制数据并记录结果

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
                        )
                    }"
        )
    }
}

来源:DeviceDetailsViewModel.kt

接收 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
                    )
                }"
    )
}

来源:DeviceDetailsViewModel.kt

日志文件列表的侧滑操作

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()
                }
            }
        }
    }
}

来源:LogFileFragment.kt

配置选项

本能力涉及的常量与配置均定义在对应文件的伴生对象或 Config 数据类中:

常量类型默认值/取值定义位置说明
KEY_DEVICEString"device"DeviceDetailsFragment传递 BluetoothDevice 的 Bundle key
Config.BLE_SERVICE_UUIDString协议固定值data.constant.Config调试用 GATT 服务 UUID(发送目标)
Config.BLE_WRITE_UUIDString协议固定值data.constant.Config调试用写特征 UUID(发送目标)
日志文本上限Int4 * 1024 * 1024 字符DeviceDetailsFragment.addLog超过后整体替换日志,防内存膨胀
时间戳格式Stringyyyy/MM/dd HH:mm:ss.SSSDeviceDetailsViewModel每条日志前缀的毫秒级时间格式
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 为空onViewCreatedfinish(0),防御性校验
蓝牙全局关闭btStateMLD = falsefinish(0)
目标设备断开(非 STATE_CONNECTED)deviceStateMLDfinish(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 的底层实现
  • 核心源码:
    • DeviceDetailsFragment.kt
    • DeviceDetailsViewModel.kt
    • LogFileFragment.kt
    • LogFileAdapter.kt
    • Config.kt
Prev
设备扫描与连接界面