一个优秀的日志系统必须满足以下三点:
- 零侵入性和零开销:开发时,可以随便打日志;发布时,日志能够自动消失,不影响运行时的性能。
- 高定位能力:我们通过日志可以快速找出所在的类和线程。
- 强关联上下文 :能将异常堆栈和业务数据完美结合起来。
我们将基于 Timber 库构建这套系统。
基础设施搭建
开启 BuildConfig
从 AGP 8.0+ 开始,许多旧的默认配置被关闭了,我们需要手动开启。
例如,BuildConfig 类默认不生成。但我们需要它来判断当前是否为 DEBUG 模式,从而控制是否启用日志开关。
只需在模块级别的 build.gradle.kts 中开启即可:
kotlin
android {
buildFeatures {
// 开启 buildConfig 支持
buildConfig = true
}
}
引入 Timber 依赖
Timber 是一个日志门面,我们只管调用它提供的接口,无需关心具体实现。我们可以种植不同的树,每棵树就是日志的具体处理逻辑。Timber 会遍历所有种下的树,来将日志分发给每一棵树。
kotlin
dependencies {
implementation("com.jakewharton.timber:timber:5.0.1")
}
初始化与分层策略
Application 初始化
我们在应用启动时,根据是否为 DEBUG 环境,使用不同的日志实现。
kotlin
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
initLogger()
}
/**
* 初始化 Logger
*/
private fun initLogger() {
if (BuildConfig.DEBUG) {
// 如果是开发环境,显示所有日志
Timber.plant(object : Timber.DebugTree() {
override fun createStackElementTag(element: StackTraceElement): String? {
return "DevLog-${super.createStackElementTag(element)}"
}
})
} else {
// 如果是生产环境,只上报警告和错误
Timber.plant(CrashReportingTree())
}
}
}
别忘了在
AndroidManifest.xml文件中注册此Application。
我们在标签前加上了 "DevLog-",这样在 Logcat 中过滤日志时,能够通过 tag:DevLog 快速过滤。
自定义 Release 树
我们来定义刚刚的 CrashReportingTree 树。
kotlin
/**
* 生产环境日志树
*/
class CrashReportingTree : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
// 去除 Verbose/Debug/Info 级别的日志,以节省性能
if (priority == Log.VERBOSE || priority == Log.DEBUG || priority == Log.INFO) {
return
}
// 上报 Warn/Error 日志到崩溃监控平台
// if (t != null) FirebaseCrashlytics.getInstance().recordException(t)
}
}
惰性求值
性能隐患
kotlin
Timber.d("User Data: ${gson.toJson(hugeObject)}")
Timber.d("User Data: %s", gson.toJson(hugeObject))
这两行代码即使是在 Release 模式下,gson.toJson(hugeObject) 也会得到执行。这是因为 Kotlin/Java 的及早求值导致的,参数会在函数调用前计算完成。
解决方法:LogExt.kt
这时,我们可以使用 Kotlin 的高阶函数,来实现只有在需要打印时,才计算参数进行字符串拼接。
kotlin
/**
* 日志扩展:惰性求值 (Lazy Evaluation)
* inline: 消除 Lambda 对象创建开销
* crossinline: 防止非局部返回
*/
inline fun logD(t: Throwable? = null, crossinline message: () -> String) {
// 只有 DEBUG 模式下,Lambda 表达式才会执行
if (BuildConfig.DEBUG) {
Timber.d(t, message())
}
}
inline fun logI(t: Throwable? = null, crossinline message: () -> String) {
if (BuildConfig.DEBUG) {
Timber.i(t, message())
}
}
inline fun logV(t: Throwable? = null, crossinline message: () -> String) {
if (BuildConfig.DEBUG) {
Timber.v(t, message())
}
}
inline fun logW(t: Throwable? = null, crossinline message: () -> String) {
// Warn 也透传给 Release Tree 进行上报
Timber.w(t, message())
}
inline fun logE(t: Throwable? = null, crossinline message: () -> String) {
// ERROR 必须透传给 Release Tree 进行上报
Timber.e(t, message())
}
使用示例:
kotlin
logD { "User profile: ${gson.toJson(user)}" }
// 带异常的写法
logE(exception) { "Database transaction failed." }
日志分级
我们常常是遇到问题就使用 Log.d,出了异常就调用 printStackTrace。
但这样并不好,清晰的日志层级能够让我们快速从大量的日志打印中查找出有用的信息。
VERBOSE (啰嗦/原子级)
定义:噪音。
场景:高频、琐碎的数据流。 比如,在 onTouchEvent 中跟踪每一次手指坐标的变化。又比如在算法循环中,在循环内部打印每一次迭代的变量。
生命周期:很短。通常在功能完成后、提交代码前就会删除。
错误示范:
kotlin
// 在 Release 包中打印,可能会导致 Logcat 缓冲区溢出
Timber.v("Touch: x=$x, y=$y")
正确示范:
kotlin
// 线上会被剥离,零开销
logV { "Touch: x=$x, y=$y" }
DEBUG (调试/痕迹)
定义:过程细节。
场景:用于验证逻辑是否符合预期,用于流程控制、参数确认。比如流程的进入和退出,变量的检查。
错误示范:
kotlin
Timber.d("Check 1") // 无意义
Timber.d("User: ${user.toJson()}") // 性能较低
正确示范:
kotlin
logD { "Login success, token saved. uid=${user.id}" }
INFO (信息/里程碑)
定义:业务流转。
场景:业务的关键节点。
比如,生命周期的变化、业务的开始和结束(如支付成功)、关键的配置信息。
错误示范:
kotlin
logI { "View clicked" }
正确示范:
kotlin
logI { "Payment finished. orderId=$orderId, duration=$time ms" }
WARN (警告/亚健康)
定义:亚健康状态。
场景:预期内的错误,通过兜底逻辑(Fallback),使得应用正常运行。
比如,加载图片失败显示默认占位图,解析配置文件失败使用了默认配置。
错误示范:
kotlin
try { ... } catch (e: Exception) {
// 吞掉异常,无法排查根本原因
logW { "Image load failed" }
}
正确示范:
kotlin
// 带上异常堆栈,且说明了使用了兜底方案
logW(e) { "Image load failed, using placeholder." }
ERROR (错误/事故现场)
定义:事故现场
场景:崩溃,或是不可修复的数据损坏。
比如,所有不打算抛出的 catch 块中,不可到达的 else 分支,丢失关键数据时。
错误示范:
kotlin
// 只有信息没有堆栈
logE { "JSON parse error: ${e.message}" }
正确示范:
kotlin
// 堆栈 + 业务数据
logE(e) { "JSON parse error. rawData=$jsonString" }
异常处理:永远不要吞掉异常堆栈,这会导致不知道出错的原因。同时,必须要关联业务上下文。
实战:一个典型的业务流程
最好的日志使用策略是"三明治法":使用 INFO 记录开始与结束,使用 DEBUG 记录过程细节,使用 WARN 记录局部的失败。
以"导入书籍"为例:
kotlin
fun importBooks(bookList: List<Book>) {
// 业务开始 -> INFO
// 确认动作已触发,记录宏观数据量
logI { "Start importing books. totalCount=${bookList.size}" }
var successCount = 0
bookList.forEach { book ->
// 循环细节 -> DEBUG
// 开发时追踪进度,线上自动屏蔽
logD { "Processing book: id=${book.id}, title=${book.title}" }
try {
// 执行具体的导入逻辑...
saveToDatabase(book)
successCount++
} catch (e: Exception) {
// 局部失败 -> WARN
// 单本书失败不影响整体流程,但需要记录原因以便排查
logW(e) { "Failed to import book: ${book.title}" }
}
}
// 业务闭环 -> INFO
// 确认任务结束,统计最终结果
logI { "Import finished. success=$successCount, failed=${bookList.size - successCount}" }
}
这样记录日志,我们能够清晰地看到每本书的处理过程,并且能一眼看出导入的运行状态(导入是否开始,导入成功和失败的个数)。
隐私合规
Google Play 对隐私保护很严格,我们不能打印用户密码、身份证号等信息。
这时,我们可以进行脱敏:
kotlin
// LogExt.kt
/**
* 脱敏处理:保留前3位和后4位
*/
fun String.mask(prefixLen: Int = 3, suffixLen: Int = 4): String {
if (length <= prefixLen + suffixLen) {
return "*".repeat(length)
}
return substring(0, prefixLen) +
"*".repeat(length - prefixLen - suffixLen) +
substring(length - suffixLen)
}
使用示例:
kotlin
val phoneNumber = "12345678910"
logD { "SMS sent to ${phoneNumber.mask()}" } // SMS sent to 123****8910