Android实战:构建高可维护的日志系统

537 阅读3分钟

一个优秀的日志系统必须满足以下三点:

  1. 零侵入性和零开销:开发时,可以随便打日志;发布时,日志能够自动消失,不影响运行时的性能。
  2. 高定位能力:我们通过日志可以快速找出所在的类和线程。
  3. 强关联上下文:能将异常堆栈和业务数据完美结合起来。

我们将基于 Timber 库构建这套系统。

基础设施搭建

开启 BuildConfig

从 AGP 8.0+ 开始,许多旧的默认配置被关闭了,我们需要手动开启。

例如,BuildConfig 类默认不生成。但我们需要它来判断当前是否为 DEBUG 模式,从而控制是否启用日志开关。

只需在模块级别的 build.gradle.kts 中开启即可:

android {
    buildFeatures {
        // 开启 buildConfig 支持
        buildConfig = true
    }
}

引入 Timber 依赖

Timber 是一个日志门面,我们只管调用它提供的接口,无需关心具体实现。我们可以种植不同的树,每棵树就是日志的具体处理逻辑。Timber 会遍历所有种下的树,来将日志分发给每一棵树。

dependencies {
    implementation("com.jakewharton.timber:timber:5.0.1")
}

初始化与分层策略

Application 初始化

我们在应用启动时,根据是否为 DEBUG 环境,使用不同的日志实现。

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 树。

/**
 * 生产环境日志树
 */
class CrashReportingTree : Timber.Tree() {
    override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
        // 过滤 Verbose 和 Debug 日志
        if (priority == Log.VERBOSE || priority == Log.DEBUG) {
            return
        }

        // 记录面包屑 (Breadcrumbs)
        // 将 INFO 和 WARN 级别的日志存入 Crashlytics 内存缓冲
        // 当后续发生崩溃时,这些日志会作为崩溃线索一起上报。
        val logMessage = "[$tag] $message"
        FirebaseCrashlytics.getInstance().log(logMessage)

        // 触发上报机制
        if (t != null) {
            // 有明确的异常堆栈 -> 上报
            FirebaseCrashlytics.getInstance().recordException(t)
        } else if (priority == Log.ERROR) {
            // 没有异常堆栈,但业务判定为 Error -> 构造异常强制上报
            FirebaseCrashlytics.getInstance().recordException(Throwable(message))
        }
    }
}

惰性求值

性能隐患

Timber.d("User Data: ${gson.toJson(hugeObject)}")
Timber.d("User Data: %s", gson.toJson(hugeObject))

这两行代码即使是在 Release 模式下,gson.toJson(hugeObject) 也会得到执行。这是因为 Kotlin/Java 的及早求值导致的,参数会在函数调用前计算完成。

解决方法:LogExt.kt

我们需要解决两个核心性能隐患:

  1. 字符串拼接的开销:避免在日志关闭时执行耗时操作。
  2. 反射开销:Timber 默认会通过反射获取类名作为 Tag。

我们可以使用 Kotlin 的 inline 高阶函数和可选的 Tag 参数,来解决这两个问题。

// LogExt.kt

/**
 * 日志扩展:惰性求值 (Lazy Evaluation) + 显式 Tag 支持
 * - inline: 消除 Lambda 对象创建开销
 * - crossinline: 防止非局部返回
 */
inline fun logD(tag: String? = null, t: Throwable? = null, crossinline message: () -> String) {
    // 只有 DEBUG 模式下,Lambda 表达式才会执行
    if (BuildConfig.DEBUG) {
        tag?.let {
            // 只有在 DEBUG 模式下,并且 tag 不为空时,才覆盖默认 Tag
            Timber.tag(it)
        }
        Timber.d(t, message())
    }
}

inline fun logI(tag: String? = null, t: Throwable? = null, crossinline message: () -> String) {
    if (BuildConfig.DEBUG) {
        tag?.let {
            Timber.tag(it)
        }
        Timber.i(t, message())
    }
}

inline fun logV(tag: String? = null, t: Throwable? = null, crossinline message: () -> String) {
    if (BuildConfig.DEBUG) {
        tag?.let {
            Timber.tag(it)
        }
        Timber.v(t, message())
    }
}

inline fun logW(tag: String? = null, t: Throwable? = null, crossinline message: () -> String) {
    // Warn 也透传给 Release Tree 进行上报
    tag?.let {
        Timber.tag(it)
    }
    Timber.w(t, message())
}

inline fun logE(tag: String? = null, t: Throwable? = null, crossinline message: () -> String) {
    // ERROR 必须透传给 Release Tree 进行上报
    tag?.let {
        Timber.tag(it)
    }
    Timber.e(t, message())
}

使用示例:

// 常规用法
logD { "User profile: ${gson.toJson(user)}" }

// 高性能场景
logV(tag = "TouchSystem") { "x=$x, y=$y" }

// 异常处理
logE(t = exception) { "Database transaction failed." }

日志分级

我们常常是遇到问题就使用 Log.d,出了异常就调用 printStackTrace

但这样并不好,清晰的日志层级能够让我们快速从大量的日志打印中查找出有用的信息。

VERBOSE (啰嗦/原子级)

定义:噪音。

场景:高频、琐碎的数据流。 比如,在 onTouchEvent 中跟踪每一次手指坐标的变化。又比如在算法循环中,在循环内部打印每一次迭代的变量。

生命周期:很短。通常在功能完成后、提交代码前就会删除。

错误示范:

// 在 Release 包中打印,可能会导致 Logcat 缓冲区溢出
Timber.v("Touch: x=$x, y=$y") 

正确示范:

// 线上会被剥离,零开销
logV { "Touch: x=$x, y=$y" }

DEBUG (调试/痕迹)

定义:过程细节。

场景:用于验证逻辑是否符合预期,用于流程控制、参数确认。比如流程的进入和退出,变量的检查。

错误示范:

Timber.d("Check 1") // 无意义
Timber.d("User: ${user.toJson()}") // 性能较低

正确示范:

logD { "Login success, token saved. uid=${user.id}" } 

INFO (信息/里程碑)

定义:业务流转。

场景:业务的关键节点。

比如,生命周期的变化、业务的开始和结束(如支付成功)、关键的配置信息。

错误示范:

logI { "View clicked" } 

正确示范:

logI { "Payment finished. orderId=$orderId, duration=$time ms" }

WARN (警告/亚健康)

定义:亚健康状态。

场景:预期内的错误,通过兜底逻辑(Fallback),使得应用正常运行。

比如,加载图片失败显示默认占位图,解析配置文件失败使用了默认配置。

错误示范:

try { ... } catch (e: Exception) {
   // 吞掉异常,无法排查根本原因
   logW { "Image load failed" } 
}

正确示范:

// 带上异常堆栈,且说明了使用了兜底方案
logW(e) { "Image load failed, using placeholder." }

ERROR (错误/事故现场)

定义:事故现场

场景:崩溃,或是不可修复的数据损坏。

比如,所有不打算抛出的 catch 块中,不可到达的 else 分支,丢失关键数据时。

错误示范:

// 只有信息没有堆栈
logE { "JSON parse error: ${e.message}" } 

正确示范:

// 堆栈 + 业务数据
logE(e) { "JSON parse error. rawData=$jsonString" }

异常处理:永远不要吞掉异常堆栈,这会导致不知道出错的原因。同时,必须要关联业务上下文。

实战:一个典型的业务流程

最好的日志使用策略是“三明治法”:使用 INFO 记录开始与结束,使用 DEBUG 记录过程细节,使用 WARN 记录局部的失败。

以“导入书籍”为例:

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 对隐私保护很严格,我们不能打印用户密码、身份证号等信息。

这时,我们可以进行脱敏:

// LogExt.kt

/**
 * 隐私脱敏处理
 */
fun String?.mask(prefixLen: Int = 3, suffixLen: Int = 4): String {
    // 空字符串
    if (this.isNullOrBlank()) {
        return "***"
    }

    // 长度不足时,全码掩盖
    if (this.length <= prefixLen + suffixLen) {
        return "*".repeat(this.length)
    }

    // 保留前 prefix 位和后 suffix 位
    return this.substring(0, prefixLen) +
            "*".repeat(this.length - prefixLen - suffixLen) +
            this.substring(this.length - suffixLen)
}

使用示例:

val phoneNumber = "12345678910"
logD { "SMS sent to ${phoneNumber.mask()}" } // SMS sent to 123****8910