如何使用 Timber 或自定义 Logger 实现日志分级和输出控制?

解读

国内面试场景下,这道题表面问“怎么打日志”,实则考察三点:

  1. 是否理解 Android 日志体系的“分级”与“开关”本质——线上包必须可关闭、可采样、可回捞;
  2. 是否能把“Timber 只是 façade”这一理念讲透,进而给出源码级定制方案;
  3. 是否兼顾性能、合规、隐私(工信部 164 号文、Google Play 政策),例如敏感字段脱敏、日志文件加密、回捞通道走 HTTPS+国密算法。

回答时切忌只贴“Timber.plant(DebugTree())”这类 Demo 代码,而要体现“分级策略 + 动态开关 + 安全输出”的闭环设计。

知识点

  1. Android 原生 Log 级别:VERBOSE(2) ≤ DEBUG(3) ≤ INFO(4) ≤ WARN(5) ≤ ERROR(6) ≤ ASSERT(7);系统属性 log.tag、ro.debuggable 可全局过滤。
  2. Timber 架构:Tree 列表模式,每个 Tree 可单独设置过滤条件;DebugTree 自动解析调用栈生成 TAG,Release 版默认不种植任何 Tree。
  3. 日志落盘与回捞:mmap/queue + 压缩 + 加密(AES-256-GCM/国密 SM4)+ 文件滚动(按大小+日期);上传通道需带采样率、用户授权、网络类型判断。
  4. ProGuard/R8 规则:-assumenosideeffects class android.util.Log { ; } 只能剪枝 Log.,无法剪枝 Timber.*,因此线上 Tree 必须“空实现”或“级别门控”。
  5. 合规红线:IMEI、MAC、定位、账号明文禁止落盘;日志文件必须存于私有目录,/sdcard/Android/data/<pkg>/files/log/ 以内;用户可一键关闭并触发立即清理。

答案

线上实战方案分三步:统一 façade → 分级策略 → 安全输出。

  1. 统一 façade
object L {
    inline fun v(throwable: Throwable? = null, message: () -> String) =
        log(Level.VERBOSE, throwable, message)
    inline fun d(throwable: Throwable? = null, message: () -> String) =
        log(Level.DEBUG, throwable, message)
    inline fun i(throwable: Throwable? = null, message: () -> String) =
        log(Level.INFO, throwable, message)
    inline fun w(throwable: Throwable? = null, message: () -> String) =
        log(Level.WARN, throwable, message)
    inline fun e(throwable: Throwable? = null, message: () -> String) =
        log(Level.ERROR, throwable, message)

    private inline fun log(level: Level, throwable: Throwable?, message: () -> String) {
        if (!LoggerConfig.isLoggable(level)) return      // 运行时开关
        val msg = message()                              // lambda 在过滤后执行,避免字符串拼接损耗
        Timber.tag(LoggerConfig.tagPrefix).log(level.value, throwable, msg)
    }
}
  1. 分级策略(基于 Timber 多 Tree)
object LoggerConfig {
    var minLevel = Level.INFO          // 远程配置可热更新
    val tagPrefix = BuildConfig.LIBRARY_PACKAGE_NAME
    fun isLoggable(level: Level) = level.value >= minLevel.value
}

class ReleaseTree : Timber.Tree() {
    override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
        if (priority < LoggerConfig.minLevel.value) return
        // 采样:1‰ 写本地,其余直接丢弃
        if (Random.nextInt(1000) != 0) return
        val record = LogRecord(timestamp = System.currentTimeMillis(),
                               level = priority, tag = tag, msg = message, tr = t)
        LogWriter.enqueue(record)   // 异步压缩加密落盘
    }
}

// Application#onCreate 内
if (BuildConfig.DEBUG) {
    Timber.plant(Timber.DebugTree())
} else {
    Timber.plant(ReleaseTree())
}
  1. 安全输出
  • 落盘:LogWriter 使用 mmap 环形缓冲区,写入前先用 SM4 加密,再用 zstd 压缩,单文件上限 512 KB,保留 3 天。
  • 上传:只在 Wi-Fi 且充电时触发,接口带 RSA+SM2 双证书校验,返回 204 即删除本地文件。
  • 动态关闭:用户“设置-隐私-本地日志”关闭后,立即调用 LoggerConfig.minLevel = Level.ASSERT,并发送广播触发 LogWriter.truncate()

通过以上设计,既满足开发阶段 verbose 全量输出,又保证线上包“零敏感明文、可热关闭、可回捞”,同时符合国内监管与 Google Play 政策。

拓展思考

  1. 折叠屏/多进程场景:WebView 运行在独立进程,需使用 ContentProvider + Binder 把日志汇总到主进程统一落盘,避免多进程写同一文件导致损坏。
  2. Kotlin 协程集成:日志写入放在 Dispatchers.IO 单例队列,结合 Channel 实现背压,防止爆内存;异常时通过 uncaughtExceptionHandler 把崩溃栈立刻刷盘。
  3. 合规 2.0:工信部 164 号文要求“日志留存 3 天+用户可删”,可在文件头部写入魔数,启动时校验完整性,被篡改则立即清空,防止取证纠纷。
  4. 性能极限:线上关闭日志后,如何彻底消除“方法调用+字符串拼接”开销?可结合 Kotlin inline + lambda + assumeNoSideEffects 自定义规则,让 R8 直接把 L.d { "userId=$id" } 剪成空语句,实现“零成本”日志。