如何使用 Timber 或自定义 Logger 实现日志分级和输出控制?
解读
国内面试场景下,这道题表面问“怎么打日志”,实则考察三点:
- 是否理解 Android 日志体系的“分级”与“开关”本质——线上包必须可关闭、可采样、可回捞;
- 是否能把“Timber 只是 façade”这一理念讲透,进而给出源码级定制方案;
- 是否兼顾性能、合规、隐私(工信部 164 号文、Google Play 政策),例如敏感字段脱敏、日志文件加密、回捞通道走 HTTPS+国密算法。
回答时切忌只贴“Timber.plant(DebugTree())”这类 Demo 代码,而要体现“分级策略 + 动态开关 + 安全输出”的闭环设计。
知识点
- Android 原生 Log 级别:VERBOSE(2) ≤ DEBUG(3) ≤ INFO(4) ≤ WARN(5) ≤ ERROR(6) ≤ ASSERT(7);系统属性 log.tag、ro.debuggable 可全局过滤。
- Timber 架构:Tree 列表模式,每个 Tree 可单独设置过滤条件;DebugTree 自动解析调用栈生成 TAG,Release 版默认不种植任何 Tree。
- 日志落盘与回捞:mmap/queue + 压缩 + 加密(AES-256-GCM/国密 SM4)+ 文件滚动(按大小+日期);上传通道需带采样率、用户授权、网络类型判断。
- ProGuard/R8 规则:-assumenosideeffects class android.util.Log { ; } 只能剪枝 Log.,无法剪枝 Timber.*,因此线上 Tree 必须“空实现”或“级别门控”。
- 合规红线:IMEI、MAC、定位、账号明文禁止落盘;日志文件必须存于私有目录,/sdcard/Android/data/<pkg>/files/log/ 以内;用户可一键关闭并触发立即清理。
答案
线上实战方案分三步:统一 façade → 分级策略 → 安全输出。
- 统一 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)
}
}
- 分级策略(基于 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())
}
- 安全输出
- 落盘:LogWriter 使用 mmap 环形缓冲区,写入前先用 SM4 加密,再用 zstd 压缩,单文件上限 512 KB,保留 3 天。
- 上传:只在 Wi-Fi 且充电时触发,接口带 RSA+SM2 双证书校验,返回 204 即删除本地文件。
- 动态关闭:用户“设置-隐私-本地日志”关闭后,立即调用
LoggerConfig.minLevel = Level.ASSERT,并发送广播触发LogWriter.truncate()。
通过以上设计,既满足开发阶段 verbose 全量输出,又保证线上包“零敏感明文、可热关闭、可回捞”,同时符合国内监管与 Google Play 政策。
拓展思考
- 折叠屏/多进程场景:WebView 运行在独立进程,需使用 ContentProvider + Binder 把日志汇总到主进程统一落盘,避免多进程写同一文件导致损坏。
- Kotlin 协程集成:日志写入放在
Dispatchers.IO单例队列,结合Channel实现背压,防止爆内存;异常时通过uncaughtExceptionHandler把崩溃栈立刻刷盘。 - 合规 2.0:工信部 164 号文要求“日志留存 3 天+用户可删”,可在文件头部写入魔数,启动时校验完整性,被篡改则立即清空,防止取证纠纷。
- 性能极限:线上关闭日志后,如何彻底消除“方法调用+字符串拼接”开销?可结合 Kotlin inline + lambda + assumeNoSideEffects 自定义规则,让 R8 直接把
L.d { "userId=$id" }剪成空语句,实现“零成本”日志。