美文网首页
如何设计 Log 工具类 —— timber 解析

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

作者: Vic_wkx | 来源:发表于2022-04-04 18:06 被阅读0次

    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<LogUtil>()
    
        @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 解析

          本文链接:https://www.haomeiwen.com/subject/fzkaqrtx.html