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

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

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

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

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

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

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

日志系统与调试指南

本页介绍 ATTConnect 示例应用(android-bt-demo)的日志系统实现与调试方法,涵盖 JL_Log 日志库的初始化配置、日志文件管理(查看/删除/清空/导出)、日志开关控制,以及如何利用日志进行蓝牙开发调试。

Purpose and Scope

本文档面向需要在 ATTConnect 工程中理解、使用或扩展日志能力的开发者,内容覆盖:

  • 日志库 jl_log(com.jieli.logcat.JL_Log)的引入方式与能力边界
  • 应用启动时日志系统的初始化流程(MyApplication)
  • 日志文件的读写、过滤、排序、删除与清空逻辑(LogFileViewModel)
  • 日志文件管理界面(LogFileFragment)及其存储权限处理
  • 日志开关在设置界面中的表现(SettingsFragment)
  • 工程中各模块使用 JL_Log 的典型调用方式与调试建议

以下内容不属于本页范围,请参阅对应页面:

  • 蓝牙扫描、连接与音频传输的具体业务逻辑(见各功能页面,本页仅以 BtScanner 为例说明日志埋点方式)
  • 杰理 SDK 底层协议解析与数据通道细节
  • 应用打包、签名与发布流程

Overview

ATTConnect 是杰理科技(Jieli)推出的安卓蓝牙耳机类应用示例。调试蓝牙外设固件时,串口日志与文件日志是定位问题的核心手段。为此,工程通过预编译 AAR 引入杰理自研日志库 jl_log_V1.0.0_10-release.aar(com.jieli.logcat.JL_Log),统一封装了:

  • 分级打印:d / i / w / e 等日志级别,每个调用携带 TAG、方法名与消息体,便于按模块过滤
  • 文件落盘:可将日志保存为 .txt 文件到指定目录,支持运行时读取与导出
  • 崩溃捕获:isLogcatCrash 选项将 Java/Kotlin 崩溃堆栈写入日志通道,便于回溯现场
  • 开关控制:通过 setLog / setSaveLogFile 全局开关,在 Release 包中彻底关闭日志与文件写入

日志系统的设计意图非常明确:让调试能力与业务代码解耦。业务模块只需调用 JL_Log.d(TAG, method, msg),至于日志是否输出、是否落盘、存到哪个目录,全部由应用入口统一配置,无需业务侧关心。同时,SettingsFragment 中通过 JL_Log.isLog() 动态控制日志路径提示的显示,说明日志开关状态是运行时可见、可感知的。

Architecture

下图展示了日志系统在应用中的整体架构与数据流:

flowchart TD
    subgraph sg_AppEntry["应用入口层"]
        MyApplication["MyApplication<br/>(onCreate → initLog)"]
        BuildConfig["BuildConfig.DEBUG<br/>(开关来源)"]
    end

    subgraph sg_LogLib["日志库 jl_log (AAR)"]
        JL_Log["JL_Log<br/>(com.jieli.logcat)"]
        LogOption["LogOption<br/>(isLogcatCrash)"]
        LogFileOption["LogFileOption<br/>(logFileDirPath)"]
        Logcat["Logcat 输出"]
        Crash["崩溃捕获"]
    end

    subgraph sg_Business["业务模块层"]
        BtScanner["BtScanner"]
        DeviceFragment["DeviceFragment"]
        HomeActivity["HomeActivity"]
        FileUtil["FileUtil"]
        SettingsFragment["SettingsFragment"]
    end

    subgraph sg_LogManage["日志管理界面层"]
        LogFileFragment["LogFileFragment"]
        LogFileViewModel["LogFileViewModel"]
        LogFileAdapter["LogFileAdapter"]
    end

    subgraph sg_Storage["存储层"]
        LogFiles["日志目录 *.txt"]
        Download["Download 目录<br/>(导出副本)"]
    end

    MyApplication -->|"setLog / setSaveLogFile / configure"| JL_Log
    BuildConfig -->|"isLog 开关"| MyApplication
    JL_Log --> LogOption
    JL_Log --> LogFileOption
    JL_Log --> Logcat
    LogOption -->|"isLogcatCrash = true"| Crash

    BtScanner -->|"JL_Log.d/i/w"| JL_Log
    DeviceFragment -->|"JL_Log.i"| JL_Log
    HomeActivity -->|"JL_Log.i"| JL_Log
    FileUtil -->|"JL_Log.w"| JL_Log

    LogFileFragment -->|"MVVM 观察"| LogFileViewModel
    LogFileViewModel -->|"读取目录"| LogFileOption
    LogFileViewModel -->|"listFiles/delete"| LogFiles
    LogFileViewModel -->|"readLogFiles/clearLog"| LogFileFragment
    LogFileAdapter -->|"渲染列表"| LogFileFragment
    LogFileFragment -->|"复制导出"| Download
    SettingsFragment -->|"JL_Log.isLog()"| JL_Log
    SettingsFragment -->|"入口跳转"| LogFileFragment

架构要点说明:

  1. 入口统一配置:MyApplication.initLog() 是日志系统的唯一初始化点。它读取 BuildConfig.DEBUG 决定日志开关,避免各模块各自判断。
  2. 业务侧零配置:业务模块(扫描、连接、UI、工具类)只做"调用",不做"配置",日志策略集中管理,降低散落开关带来的不一致风险。
  3. MVVM 管理日志文件:LogFileFragment(View)+ LogFileViewModel(ViewModel)+ LogFileAdapter(Adapter)构成日志文件管理界面,通过 MutableLiveData 驱动 UI 刷新。
  4. 存储解耦:日志文件写入目录由 JL_Log.getLogOption().logFileOption.logFileDirPath 提供,界面层无需关心实际路径来源;导出时通过 Android 媒体库(MediaStore)复制到 Download 目录,适配高版本存储权限。

日志初始化与全局配置

日志系统的入口位于 MyApplication。应用进程启动时,onCreate() 调用 initLog(context) 完成全部日志配置:

/**
 * 初始化打印日志
 */
private fun initLog(context: Context) {
    val isLog = BuildConfig.DEBUG
    JL_Log.setLog(isLog)
    JL_Log.setSaveLogFile(isLog, context)
    if (isLog) {
        JL_Log.configure(JL_Log.getLogOption().apply {
            isLogcatCrash = true
        })
    }
}

Source: MyApplication.kt

这段代码体现了三个关键设计决策:

  1. 以 BuildConfig.DEBUG 作为唯一开关源:Debug 构建自动开启日志与文件保存,Release 构建自动关闭。开发者无需手动切换,避免了发布包泄露日志文件或拖慢性能的风险。
  2. 日志开关与文件保存开关分离:setLog(isLog) 控制是否输出到 Logcat,setSaveLogFile(isLog, context) 控制是否落盘。两个开关可以独立控制,为"只打日志不写文件"或"只写文件不打屏"等调试场景留出余地。
  3. 崩溃捕获默认随 Debug 开启:isLogcatCrash = true 使崩溃堆栈写入日志通道。注意此处使用的是 configure(LogOption) 的 Builder 风格链式调用,apply 块内修改 LogOption 的属性后整体提交。

日志开关的运行时感知

SettingsFragment 在设置界面中通过 JL_Log.isLog() 查询当前日志状态,决定是否显示日志路径提示:

binding.viewLogcatPath.root.apply {
    if (JL_Log.isLog()) {
        binding.tvLogcatInfo.show()
    }
}

Source: SettingsFragment.kt

设计意图:设置界面将"日志文件管理"入口与日志开关状态联动——日志未开启时隐藏路径提示,避免误导用户去一个空目录寻找日志。

日志文件管理实现

日志文件的管理逻辑集中在 LogFileViewModel,它作为日志管理界面的 ViewModel,封装了目录读取、文件过滤、删除、清空等操作。

日志目录的获取

val logFileDirPath = JL_Log.getLogOption().logFileOption.logFileDirPath

Source: LogFileViewModel.kt

日志文件目录路径由日志库的 LogFileOption 提供,应用层不硬编码路径。这样当日志库升级调整默认目录时,界面层无需改动。

读取日志文件列表

fun readLogFiles() {
    val folder = File(logFileDirPath)
    if (!folder.exists()) return
    folder.listFiles()?.filter {
        it.isFile && it.length() >= 1024
                && (it.name.endsWith(".txt") || it.name.endsWith(".TXT"))
    }.also { list ->
        val files = list?.toMutableList() ?: mutableListOf()
        files.sortByDescending { it.lastModified() }
        logFilesMLD.postValue(files)
    }
}

Source: LogFileViewModel.kt

readLogFiles() 的过滤规则体现了对"有效日志"的定义:

  • it.isFile:排除目录项
  • it.length() >= 1024:过滤小于 1KB 的文件——小于 1KB 的日志通常是空壳或启动残留,没有查看价值,直接隐藏以减少干扰
  • 扩展名 .txt / .TXT:大小写双匹配,兼容日志库在不同环境下生成的文件命名

排序采用 sortByDescending { it.lastModified() },最新日志排在最前,符合"先看最新问题"的调试直觉。结果通过 MutableLiveData 的 postValue 异步发布,保证线程安全。

删除与清空

fun deleteFile(filePath: String) {
    if (FileUtil.deleteFile(File(filePath))) {
        readLogFiles()
        return
    }
    opResMLD.postValue(
        OpResult(
            OP_DELETE_FILE,
            message = "Failed to delete File.\n filePath : $filePath",
            data = false
        )
    )
}

fun clearLog() {
    if (FileUtil.deleteFile(File(logFileDirPath))) {
        logFilesMLD.postValue(mutableListOf())
        return
    }
    opResMLD.postValue(
        OpResult(
            OP_DELETE_FOLDER,
            message = "Failed to delete folder.\n filePath : $logFileDirPath",
            data = false
        )
    )
}

Source: LogFileViewModel.kt

两个操作共用同一套结果通知机制:成功时直接刷新数据源(删除单文件后重新 readLogFiles(),清空目录后发布空列表),失败时通过 OpResult 携带操作码上报。操作码常量定义在伴生对象中:

常量值含义
OP_DELETE_FILE0x20删除单个日志文件
OP_DELETE_FOLDER0x21清空整个日志目录

设计意图:文件删除是不可逆操作,因此失败必须显式告知 UI 层弹提示,而不是静默吞掉;成功路径则不打扰用户,直接以列表刷新作为反馈。

日志文件管理界面

LogFileFragment 是日志文件管理的 UI 层,基于 BasicFragment + ViewBinding + MVVM 构建。它使用 permissions.dispatcher 注解处理运行时存储权限,是理解"日志导出"流程的关键。

存储权限处理

Android 高版本对存储访问有严格限制,因此界面在复制/导出文件前必须请求读写权限:

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

Source: LogFileFragment.kt

配合 @OnShowRationale(展示权限原因说明)与 @OnPermissionDenied(拒绝后弹提示并关闭对话框)三个注解,形成完整的权限请求闭环。值得注意的细节:连权限检查本身都用 JL_Log.d 打日志,便于复现权限相关问题时在 Logcat 中定位。

界面交互流程

  • 工具栏左侧返回按钮:finish(100) 关闭当前页面
  • 工具栏右侧清空按钮(ic_cleaning_black 图标):调用 viewModel.clearLog() 一键清空全部日志
  • 列表项(LogFileAdapter + SwipeMenuLayout):支持侧滑删除单条日志;点击条目触发权限请求 → 复制文件到 Download 目录

核心流程

下图完整呈现"查看日志文件 → 导出到 Download 目录"的运行时序列:

sequenceDiagram
    participant U as 用户
    participant F as LogFileFragment
    participant VM as LogFileViewModel
    participant JL as JL_Log 日志库
    participant P as PermissionUtil
    participant M as MediaStore/Download

    U->>F: 打开日志文件管理页
    F->>VM: onViewCreated → readLogFiles()
    VM->>JL: getLogOption().logFileOption.logFileDirPath
    JL-->>VM: 日志目录路径
    VM->>VM: listFiles 过滤(.txt, ≥1KB)
    VM-->>F: logFilesMLD.postValue(文件列表)
    F->>F: adapter 刷新 UI

    U->>F: 点击某个日志文件
    F->>P: 检查 READ/WRITE 权限
    alt 已有权限
        P-->>F: 已授权
        F->>F: copyFileToDownloadFolder(filePath)
        F->>M: 写入 Download 目录
    else 未授权
        F->>F: 触发 @NeedsPermission 请求
        P-->>F: 用户授予
        F->>F: copyFileToDownloadFolder(filePath)
        F->>M: 写入 Download 目录
    end

    U->>F: 点击清空按钮
    F->>VM: clearLog()
    VM->>VM: FileUtil.deleteFile(日志目录)
    VM-->>F: 成功 → 空列表 / 失败 → OpResult(0x21)

流程要点:

  1. 页面打开即触发 readLogFiles(),异步加载后经 LiveData 刷新列表,不阻塞主线程。
  2. 导出前统一走权限请求分支,@OnShowRationale 与 @OnPermissionDenied 兜底所有用户选择。
  3. 清空操作走 FileUtil.deleteFile 删除整个目录,成功后 UI 直接置空列表——比逐条删除更彻底,是"恢复出厂日志"的快捷方式。
  4. 失败路径统一由 opResMLD 发布 OpResult,携带操作码与失败原因字符串,UI 层据此弹窗提示。

使用示例:业务模块的日志埋点

业务代码中的日志调用统一采用 JL_Log.<level>(TAG, methodName, message) 三参数形式。以下是从 BtScanner(蓝牙扫描器)中提取的真实示例,展示了扫描状态机各阶段的日志埋点方式:

if (isStart) {
    JL_Log.d(TAG, "postDiscoveryState", "start... ")
    BluetoothUtil.getSystemConnectedBtDeviceList(context)?.let { devices ->
        // ...
    }
}

Source: BtScanner.kt

BluetoothAdapter.ACTION_DISCOVERY_STARTED -> {
    JL_Log.d(TAG, "ACTION_DISCOVERY_STARTED", "--->")
}
BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> {
    JL_Log.d(TAG, "ACTION_DISCOVERY_FINISHED", "--->")
    stopScan()
}

Source: BtScanner.kt

这种埋点风格的设计意图:

  • TAG 定位模块:每个类持有自己的 TAG(通常为类名),Logcat 过滤时按 TAG 精准筛选,避免多模块日志混杂
  • 第二参数标方法名/事件名:即使日志消息为空,也能从方法名看出执行路径
  • "--->" 风格消息:用于标记事件进入/离开点,配合时间戳可还原时序,对排查扫描启动/停止这类竞态问题特别有效

其他级别的典型调用(FileUtil 中的警告日志、HomeActivity/DeviceFragment 中的信息日志):

// warning 级别:文件创建失败告警
if (!file.mkdir()) {
    JL_Log.w(TAG, "createFilePath", "Failed to create folder: $path")
}

// info 级别:页面生命周期/权限事件
JL_Log.i(TAG, "onLocationPermissionDenied", "...")

// 扫描去重保护
if (viewModel.isScanning()) {
    JL_Log.d(TAG, "scanDevice", "isScanning : true")
    return
}

Sources: FileUtil.kt, DeviceFragment.kt

配置选项

日志系统的配置全部集中在 MyApplication.initLog() 中完成,通过 JL_Log 的静态方法设置:

配置项类型默认值说明
JL_Log.setLog(isLog)BooleanBuildConfig.DEBUG全局日志开关。false 时所有日志输出被禁用
JL_Log.setSaveLogFile(isLog, context)Boolean + ContextBuildConfig.DEBUG是否将日志保存为 .txt 文件。需要 Context 以确定应用存储目录
LogOption.isLogcatCrashBooleanfalse(Debug 下被置为 true)是否将崩溃堆栈写入日志通道。仅 Debug 构建开启
LogFileOption.logFileDirPathString由日志库决定日志文件存储目录路径。通过 JL_Log.getLogOption().logFileOption 读取,界面层不硬编码

关键约束: 日志配置必须在业务代码调用 JL_Log 之前完成(Application.onCreate 是最早的执行点),否则早期日志可能丢失。开关来源于 BuildConfig.DEBUG,意味着切换构建类型(debug/release)即可整体切换日志行为,无需改动业务代码。

API 参考:JL_Log 常用方法

以下为工程中实际使用的 com.jieli.logcat.JL_Log API(基于调用点归纳,完整签名以 AAR 为准):

方法参数用途工程内示例位置
setLog(enable: Boolean)是否开启日志全局开关,控制所有日志输出MyApplication.initLog()
setSaveLogFile(enable: Boolean, context: Context)开关 + 上下文控制日志文件落盘MyApplication.initLog()
configure(option: LogOption)日志选项对象提交高级配置(如崩溃捕获)MyApplication.initLog()
getLogOption(): LogOption—获取当前日志选项(含 logFileOption.logFileDirPath)LogFileViewModel
isLog(): Boolean—查询日志是否开启SettingsFragment
d(tag, method, msg)标签、方法名、消息输出 Debug 级别日志BtScanner / LogFileFragment
i(tag, method, msg)标签、方法名、消息输出 Info 级别日志HomeActivity / DeviceFragment
w(tag, method, msg)标签、方法名、消息输出 Warn 级别日志FileUtil

调用约定: 日志消息使用字符串模板("xxx : $value")嵌入关键变量;第二个参数传方法名或事件名(如 "postDiscoveryState"、"ACTION_DISCOVERY_STARTED"),便于 Logcat 按事件过滤。

故障模式、边界情况与调试建议

文件过滤导致的"看不到日志"

readLogFiles() 要求文件 >= 1024 字节且扩展名为 .txt/.TXT。若刚启动应用、日志尚未积累到 1KB,或日志库输出到其他扩展名文件,列表会显示为空。调试建议:先触发一些业务操作(如扫描设备)再刷新列表;确认日志目录真实内容可用 adb shell ls <logFileDirPath> 核对。

权限拒绝导致导出失败

导出依赖 READ/WRITE_EXTERNAL_STORAGE 权限。@OnPermissionDenied 分支会调用 showTips(getString(R.string.missing_permission_desc)) 提示用户。注意:Android 11+ 的存储分区(Scoped Storage)策略下,WRITE_EXTERNAL_STORAGE 对应用专属目录内文件的复制可能受限,导出逻辑已改用 MediaStore 写入 Download 目录(见 copyFileToDownloadFolder 相关代码),属于对高版本系统的适配。

删除失败与结果通知

FileUtil.deleteFile 返回 false(如文件被占用、权限异常)时,ViewModel 通过 OpResult(OP_DELETE_FILE/OP_DELETE_FOLDER, message=..., data=false) 通知 UI。由于 OpResult 是数据类且经 LiveData 发布,UI 需在观察者中根据 opCode 区分"删文件失败"与"清空失败"两种提示文案。

Debug/Release 行为差异

Release 构建下 isLog = false,日志完全不输出、不落盘,SettingsFragment 也会隐藏日志路径提示(tvLogcatInfo)。因此线上问题无法从本机日志回溯——这是有意的安全与性能取舍。如需在发布包中定位问题,应在 Debug 构建中复现,或依赖 isLogcatCrash 捕获的崩溃堆栈。

并发与线程安全

LogFileViewModel 使用 postValue(而非 setValue)发布结果,允许在后台线程安全更新;readLogFiles() 中的文件 IO 直接运行在调用线程(主线程),对于文件数量极多的大目录场景,存在轻微卡顿风险,扩展时可迁移到 Dispatchers.IO。日志库自身的写文件线程模型由 AAR 内部管理,业务侧无需处理。

扩展点与操作建议

  • 新增日志模块:新类中定义 private val TAG = XXX::class.simpleName,统一使用 JL_Log.d/i/w/e 三参数形式,即可自动纳入现有日志体系。
  • 按模块过滤调试:Logcat 中按 TAG 过滤(如 tag:BtScanner),结合 "--->" 事件标记还原扫描时序。
  • 调整日志目录:如需自定义目录,可在 initLog() 中修改 LogOption.logFileOption(当前示例保持库默认值)。
  • 扩充崩溃现场信息:isLogcatCrash = true 已开启,若需更多现场数据(如蓝牙连接状态快照),可在崩溃回调中追加自定义日志。

Related Links

  • MyApplication.kt(日志初始化)
  • LogFileViewModel.kt(日志文件逻辑)
  • LogFileFragment.kt(日志文件界面)
  • SettingsFragment.kt(设置入口与日志开关联动)
  • BtScanner.kt(日志埋点示例:蓝牙扫描)
  • FileUtil.kt(文件删除工具)
  • 日志库 AAR:jl_log_V1.0.0_10-release.aar
Prev
协议配置常量