日志系统与调试指南
本页介绍 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
架构要点说明:
- 入口统一配置:
MyApplication.initLog()是日志系统的唯一初始化点。它读取BuildConfig.DEBUG决定日志开关,避免各模块各自判断。 - 业务侧零配置:业务模块(扫描、连接、UI、工具类)只做"调用",不做"配置",日志策略集中管理,降低散落开关带来的不一致风险。
- MVVM 管理日志文件:
LogFileFragment(View)+LogFileViewModel(ViewModel)+LogFileAdapter(Adapter)构成日志文件管理界面,通过MutableLiveData驱动 UI 刷新。 - 存储解耦:日志文件写入目录由
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
这段代码体现了三个关键设计决策:
- 以
BuildConfig.DEBUG作为唯一开关源:Debug 构建自动开启日志与文件保存,Release 构建自动关闭。开发者无需手动切换,避免了发布包泄露日志文件或拖慢性能的风险。 - 日志开关与文件保存开关分离:
setLog(isLog)控制是否输出到 Logcat,setSaveLogFile(isLog, context)控制是否落盘。两个开关可以独立控制,为"只打日志不写文件"或"只写文件不打屏"等调试场景留出余地。 - 崩溃捕获默认随 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_FILE | 0x20 | 删除单个日志文件 |
OP_DELETE_FOLDER | 0x21 | 清空整个日志目录 |
设计意图:文件删除是不可逆操作,因此失败必须显式告知 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)
流程要点:
- 页面打开即触发
readLogFiles(),异步加载后经 LiveData 刷新列表,不阻塞主线程。 - 导出前统一走权限请求分支,
@OnShowRationale与@OnPermissionDenied兜底所有用户选择。 - 清空操作走
FileUtil.deleteFile删除整个目录,成功后 UI 直接置空列表——比逐条删除更彻底,是"恢复出厂日志"的快捷方式。 - 失败路径统一由
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) | Boolean | BuildConfig.DEBUG | 全局日志开关。false 时所有日志输出被禁用 |
JL_Log.setSaveLogFile(isLog, context) | Boolean + Context | BuildConfig.DEBUG | 是否将日志保存为 .txt 文件。需要 Context 以确定应用存储目录 |
LogOption.isLogcatCrash | Boolean | false(Debug 下被置为 true) | 是否将崩溃堆栈写入日志通道。仅 Debug 构建开启 |
LogFileOption.logFileDirPath | String | 由日志库决定 | 日志文件存储目录路径。通过 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已开启,若需更多现场数据(如蓝牙连接状态快照),可在崩溃回调中追加自定义日志。