如何设计 Log 工具类 —— timber 解析

Log 类简介

不论是日常开发调试,还是用户行为分析,日志都扮演着不可或缺的角色。从日志中我们可以看出程序运行时的状态,用户进行了哪些操作等等。

Android 为我们提供了一个 Log 类来打印日志,通常,我们只需要调用 Log.d 就可以将 debug 日志打印到控制台,非常方便。

郭神在《第一行代码》中教我们写的第一行代码就是打印日志:

Log.d("MainActivity", "onCreate execute")

并且,书中向我们介绍了 Log 类的 5 个常用方法:

  • Log.v()。用于打印那些最为琐碎的、意义最小的日志信息。对应级别 verbose,是 Android 日志里面级别最低的一种。
  • Log.d()。用于打印一些调试信息,这些信息对你调试程序和分析问题应该是有帮助的。对应级别 debug,比 verbose 高一级。
  • Log.i()。用于打印一些比较重要的数据,这些数据应该是你非常想看到的、可以帮你分析用户行为的数据。对应级别 info,比 debug 高一级。
  • Log.w()。用于打印一些警告信息,提示程序在这个地方可能会有潜在风险,最好去修复一下这些出现警告的地方。对应级别 warn,比 info 高一级。
  • Log.e()。用于打印程序中的错误信息,比如程序进入了 catch 语句中。当有错误信息打印出来的时候,一般代表你的程序出现严重问题了,必须尽快修复。对应级别 error,比 warn 高一级。

有了这几个方法,已经足够应付绝大多数的开发场景了。但如果想要在项目中使用还远远不够。

一、android 为我们提供的 Log

不论是日常开发调试,还是用户行为分析,我们都需要打印日志。android 也为我们提供了 Log 类,只需要调用 Log.d 就可以将 debug 日志打印到控制台,非常方便。

Log 类中一共提供了 vdiwewtf 六个常用方法,分别对应 verbosedebuginfowarningerrorwhat a terrible failure 六种级别的日志,重要程度由低到高。

观察 Log 类的源码可以发现,这六个方法都会调用 println 方法:

println(int bufID, int priority, String tag, String msg)

其中,priority 就代表日志的级别,这个参数是以下六个常量之一:

public static final int VERBOSE = 2;
public static final int DEBUG = 3;
public static final int INFO = 4;
public static final int WARN = 5;
public static final int ERROR = 6;
public static final int ASSERT = 7;

总体来说,android 为我们提供的 Log 类还是非常简单好用的。但在实际工作中,仅把日志输出到控制台往往是不够的,我们还需要将线上的日志记录到文件中,以便于分析线上发生的异常。

封装 LogUtil,实现打印日志到文件

想要实现将日志输出到文件,我们只需要做个简单的封装就可以了:

class LogUtil {
    private val logFile = File(MyApplication.application.filesDir, "log.txt")
    fun log(priority: Int, tag: String, message: String) {
        // print to logcat
        Log.println(priority, tag, message)
        // print to file
        if (!logFile.exists()) {
            val logFileCreated = logFile.createNewFile()
            if (!logFileCreated) throw Exception("Log file created failed.")
        }
        BufferedWriter(FileWriter(logFile, true)).use {
            it.write("${SimpleDateFormat.getDateTimeInstance().format(Date())} $tag $message\n")
        }
    }
}

可以看到,在调用 LogUtil.log 方法时,首先调用 Log.println 方法将其输出到控制台,然后创建 logFile 文件,使用 BufferedWriter 将其写入到文件中。

这样的封装很直观,那么还有什么问题吗?

职责分离

现在的 LogUtil 做了两件事:一是打印日志到控制台,二是打印日志到文件。这已经违反了设计的单一职责原则。不过看起来还好,这个类还不至于复杂到需要重构。

这时我们有了新的需求:在线上日志中,我们要重点关注 debug 级别以上的日志,如果程序运行时打印出了 debug 级别以上的日志,我们需要立即将其上传到服务器上。

为了实现这个需求,我们需要修改 LogUtil 类:

class LogUtil {
    ...
    fun log(priority: Int, tag: String, message: String) {
        ...
        if (priority > Log.DEBUG) {
            // upload to server
            ...
        }
    }
}

这时,LogUtil 类做了三件事,并且这三件事是完全独立的,代码开始呈现出“坏味道”。

所以,我们可以对这个类进行重构,将这个类拆分出三个独立的 LogUtil,每个 LogUtil 只负责做一件事。

先定义统一的接口:

interface LogUtil {
    fun log(priority: Int, tag: String, message: String)
}

负责打印到 Log 控制台的 DebugLogUtil:

class DebugLogUtil : LogUtil {
    override fun log(priority: Int, tag: String, message: String) {
        Log.println(priority, tag, message)
    }
}

负责打印到文件的 PrintToFileLogUtil:

class PrintToFileLogUtil(fileName: String) : LogUtil {
    private val logFile = File(MyApplication.application.filesDir, fileName)
    override fun log(priority: Int, tag: String, message: String) {
        if (!logFile.exists()) {
            val logFileCreated = logFile.createNewFile()
            if (!logFileCreated) throw Exception("Log file created failed.")
        }
        BufferedWriter(FileWriter(logFile, true)).use {
            it.write("${SimpleDateFormat.getDateTimeInstance().format(Date())} $tag $message\n")
        }
    }
}

负责上报错误日志的 ErrorReportLogUtil:

class ErrorReportLogUtil : LogUtil {
    override fun log(priority: Int, tag: String, message: String) {
        if (priority > Log.DEBUG) {
            // upload to server
            ...
        }
    }
}

然后,将这三个 LogUtil 都放到 LogUtils 中进行管理:

object LogUtils {
    private val logUtils = mutableListOf()

    @Synchronized
    fun add(logUtil: LogUtil) {
        logUtils.add(logUtil)
    }

    @Synchronized
    fun remove(logUtil: LogUtil) {
        logUtils.remove(logUtil)
    }
    
    private fun log(priority: Int, tag: String, message: String) {
        logUtils.forEach {
            it.log(priority, tag, message)
        }
    }
}

可以看到,只要通过 add 方法将单个的 LogUtil 类添加进来,当调用 LogUtils.log 时,就会依次调用所有的 LogUtil 类,这样就完成了职责分离。

自动解析 tag

在打印日志时,通常我们使用的 tag 都是当前类的类名,常见的写法在类中定义一个 TAG 变量:

class MainActivity : Activity() {
    companion object {
        private val TAG = MainActivity::class.java.simpleName
    }
    ...
}

实际上在代码运行时,我们可以自动解析出当前类的类名,这样就可以节省一个 tag 变量。

如何自动解析当前类的类名呢?我们知道,在应用 crash 时,抛出的异常会带有当前调用栈的信息。我们可以就从这里入手,从 Throwable 中获取到当前调用栈,从栈中找出当前类的类名。

我们在 LogUtils.log 方法中,调用 Throwable().stackTraceToString() 方法,可以看到 Throwable 的 stackTrace 记录的信息如下:

java.lang.Throwable
        at com.library.logutils.LogUtils.log(LogUtils.kt:51)
        at com.library.logutils.LogUtils.d(LogUtils.kt:30)
        at com.library.logutils.LogUtils.d(LogUtils.kt:29)
        at com.library.logutils.LogUtils.d(LogUtils.kt:28)
        at com.example.logutils.MainActivity.onCreate$lambda-1(MainActivity.kt:18)
        at com.example.logutils.MainActivity.$r8$lambda$0mnlVN32oLJyTLjlyr34vx9-Els(Unknown Source:0)
        at com.example.logutils.MainActivity$$ExternalSyntheticLambda1.onClick(Unknown Source:0)
        at android.view.View.performClick(View.java:7251)
        at android.view.View.performClickInternal(View.java:7228)
        at android.view.View.access$3500(View.java:802)
        at android.view.View$PerformClick.run(View.java:27843)
        at android.os.Handler.handleCallback(Handler.java:883)
        at android.os.Handler.dispatchMessage(Handler.java:100)
        at android.os.Looper.loop(Looper.java:214)
        at android.app.ActivityThread.main(ActivityThread.java:7116)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:925)

可以看到,调用栈中 LogUtils 下一个类就是调用 LogUtils 的类,也就是我们需要的类,我们可以通过这个信息实现自动解析 tag 的功能。

object LogUtils {
    ...
    private const val DEFAULT_TAG = "UNKNOWN"

    private fun log(priority: Int, tag: String, message: String) {
        val printTag = if (tag.isEmpty()) findTag() else tag
        logUtils.forEach {
            it.log(priority, printTag, message)
        }
    }
    
    private fun findTag(): String {
        val trace = Throwable().stackTrace.firstOrNull { it.className != this::class.java.name }
        trace ?: return DEFAULT_TAG
        return trace.fileName?.split(".")?.firstOrNull() ?: DEFAULT_TAG
    }
}

Throwable 的 stackTrace 中,第一个不为当前类名的路径,就是调用 LogUtils 的路径,这个路径中的 fileName 通常就是我们需要的 tag 了。

为什么说通常呢?这是因为 fileName 不一定是类名,因为一个文件中可以有多个类,为了解决这种情况,我们可以用 className 来获取 tag:

private val ANONYMOUS_CLASS = Pattern.compile("(\\$\\d+)+$")
private fun findTag(): String {
    val trace = Throwable().stackTrace.firstOrNull { it.className != this::class.java.name }
    trace ?: return DEFAULT_TAG
    var tag = trace.className.substringAfterLast('.')
    val m = ANONYMOUS_CLASS.matcher(tag)
    if (m.find()) {
        tag = m.replaceAll("")
    }
    return tag
}

通常来说,className 的格式类似于 com.example.logutils.MainActivity,我们只需要将其以 . 号分割出最后一个字符串即可。但匿名内部类的 className 却会自动添加 $1$2 这样的后缀,所以我们用了正则表达式将 $\d 这样的后缀给替换掉。

另外,在 android API 26 以前,tag 的长度被限制为最大 23,所以我们在返回 tag 之前还要判断一下当前的 API 版本,如果超出了长度限制需要对 tag 进行裁剪:

private const val MAX_TAG_LENGTH = 23
private fun findTag(): String {
    ...
    // Tag length was limited before API 26
    if (tag.length > MAX_TAG_LENGTH && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
        return tag.substring(0, MAX_TAG_LENGTH)
    }
    return tag
}

这样就实现了自动解析 tag 的功能。

定位代码行数,点击自动跳转到调用处

在观察 Throwable 的调用栈时,我们发现 Android Studio 有一个非常好用的功能,那就是调用路径是可点击的,点一下就能自动跳转到对应的代码位置。

点击代码行数自动跳转

那么这个功能是怎么实现的呢?我们自己打印的 Log 能实现这样的功能吗?

实际上这个功能实现非常简单,我们不妨在 MainActivity.kt 文件中,打印这样一条普通的日志:

Log.d("~~~", "(MainActivity.kt:10)")

运行程序,在 Logcat 控制台查看这条日志,就会发现它打印出来是蓝色的,并且可以点击自动跳转到 MainActivity.kt 文件的第 10 行。

实现点击代码函数自动跳转

也就是说,这个自动跳转的功能是 Android Studio 为我们自动封装好的,我们需要做的就是把文件名字、代码行数找到,并按照 (文件名:代码行数) 的格式打印日志就可以了。

那么如何找到代码行数呢?其实这个信息在 Throwable 的 stackTrace 里面已经保存好了。我们只需要将其取出来就行了。

private fun findLocation(): String {
    val trace = Throwable().stackTrace.firstOrNull { it.className != this::class.java.name }
    trace ?: return ""
    if (trace.methodName.isNullOrEmpty() || trace.fileName.isNullOrEmpty() || trace.lineNumber <= 0) return ""
    return "Location: ${trace.methodName}(${trace.fileName}:${trace.lineNumber})"
}

这里笔者不仅打印了代码行数,顺带把记录方法名的 methodName 也打印了出来。

打印当前线程名

在多线程运行时,我们有时候需要知道当前线程的名字,以及其是否是主线程。所以我们可以在打印日志时,将线程信息也打印出来,便于日后分析:

private fun findThread(): String {
    return "Thread-Name: ${Thread.currentThread().name}, isMain: ${Looper.getMainLooper() == Looper.myLooper()}"
}

有的读者可能会有疑问,主线程的名字都是 "main",直接从线程名字就能看出是否是主线程了,还需要判断 Looper 吗?

这是因为子线程也可以被手动命名成 "main",所以使用 Looper 判断会更加准确。

还能做什么?

试想这样一个场景,我们写了一个仓库类,这个类中有一个 save 方法和一个 delete 方法,分别用于存储和删除数据

object Repository {
    fun save() {
        LogUtils.d("save")
        ...
    }

    fun delete() {
        LogUtils.d("delete")
        ...
    }
}

为了便于追踪仓库的修改情况,我们在这两个方法中都打印了日志。

这样打印出来的日志,代码行数始终定位在 Repository 中,对我们分析日志帮助不大。实际上我们更需要知道的是谁在调用这两个方法。

当然,我们可以在调用处打印日志解决这个问题。但如果在这两个方法中打印日志时,可以直接定位到调用这两个函数的位置,岂不是更加方便?

通过前文的调用栈分析,我们发现这是完全可行的,只要我们在寻找调用位置时,再往栈中多找几步即可。

我们用一个 stackOffset 参数来实现此功能。

private fun log(priority: Int, tag: String, message: String, stackOffset: Int) {
    var mutableStackOffset = stackOffset
    val trace = Throwable().stackTrace.firstOrNull { it.className != this::class.java.name && mutableStackOffset-- == 0 }
    val printTag = if (tag.isEmpty()) findTag(trace) else tag
    val location = findLocation(trace)
    ...
}

可以看到,我们在 stackTrace 中寻找调用位置时,找到调用 LogUtils 的路径后,继续往前寻找 stackOffset 步。比如 stackOffset 传入 1,就能找到调用"调用 LogUtils"的位置

这个功能在工具类中打印日志时非常好用。

timber 解析

timber 是 JakeWharton 大佬封装的日志工具类。本文的职责分离思想、自动解析 tag 功能就来自于 timber。

timber 直译为木材,想要使用 timber,只需要在应用的 Application 中,使用 Timber.plant(new DebugTree()); 种植一棵 Debug 树,然后就可以使用 Timber.d("message") 打印 debug 日志到控制台。

Timber 对应本文的 LogUtils,它是所有 Log 工具类的集合。plant 方法对应 add 方法,用于种植一棵树,也就是添加一个 Log 工具类。

uproot 方法对应 remove 方法,直译为“连根拔起”,也就是移除一个 Log 工具类。

/** Add a new logging tree. */
@JvmStatic fun plant(tree: Tree) {
  require(tree !== this) { "Cannot plant Timber into itself." }
  synchronized(trees) {
    trees.add(tree)
    treeArray = trees.toTypedArray()
  }
}
/** Remove a planted tree. */
@JvmStatic fun uproot(tree: Tree) {
  synchronized(trees) {
    require(trees.remove(tree)) { "Cannot uproot tree which is not planted: $tree" }
    treeArray = trees.toTypedArray()
  }
}

调用 Timber 的某个方法时,Timber 就会依次调用其包含的日志工具类,Timber 的伴生对象被命名为 Forest,即包含许多树的森林:

companion object Forest : Tree() {
    /** Log at `priority` a message with optional format args. */
    @JvmStatic override fun log(priority: Int, @NonNls message: String?, vararg args: Any?) {
      treeArray.forEach { it.log(priority, message, *args) }
    }
}

Logcat 控制台限制了日志的最大长度,最大长度是 4096,并且这个长度包含了日志中的时间等信息。所以如果输出的日志内容过长,我们需要将其裁剪后,分段输出。

override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
  if (message.length < MAX_LOG_LENGTH) {
    if (priority == Log.ASSERT) {
      Log.wtf(tag, message)
    } else {
      Log.println(priority, tag, message)
    }
    return
  }
  // Split by line, then ensure each line can fit into Log's maximum length.
  var i = 0
  val length = message.length
  while (i < length) {
    var newline = message.indexOf('\n', i)
    newline = if (newline != -1) newline else length
    do {
      val end = Math.min(newline, i + MAX_LOG_LENGTH)
      val part = message.substring(i, end)
      if (priority == Log.ASSERT) {
        Log.wtf(tag, part)
      } else {
        Log.println(priority, tag, part)
      }
      i = end
    } while (i < newline)
    i++
  }
}

另外,JakeWharton 还为这个小小的日志工具类设计了详尽的测试用例,还添加了 lint 检查。

可以看出,我等普通程序员在缺少工具类时,就打开 github 寻找三方库,而大佬在缺少工具类时,就自己手写一个三方库。再次感到世界的参差...

你可能感兴趣的:(如何设计 Log 工具类 —— timber 解析)