如何設計 Log 工具類 —— timber 解析

Log 類簡介

不論是日常開發(fā)調(diào)試,還是用戶行為分析佩微,日志都扮演著不可或缺的角色缝彬。從日志中我們可以看出程序運行時的狀態(tài),用戶進行了哪些操作等等喊衫。

Android 為我們提供了一個 Log 類來打印日志跌造,通常,我們只需要調(diào)用 Log.d 就可以將 debug 日志打印到控制臺族购,非常方便。

郭神在《第一行代碼》中教我們寫的第一行代碼就是打印日志:

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

并且陵珍,書中向我們介紹了 Log 類的 5 個常用方法:

  • Log.v()寝杖。用于打印那些最為瑣碎的、意義最小的日志信息互纯。對應級別 verbose瑟幕,是 Android 日志里面級別最低的一種。
  • Log.d()。用于打印一些調(diào)試信息只盹,這些信息對你調(diào)試程序和分析問題應該是有幫助的辣往。對應級別 debug,比 verbose 高一級殖卑。
  • Log.i()站削。用于打印一些比較重要的數(shù)據(jù),這些數(shù)據(jù)應該是你非常想看到的孵稽、可以幫你分析用戶行為的數(shù)據(jù)许起。對應級別 info,比 debug 高一級菩鲜。
  • Log.w()园细。用于打印一些警告信息,提示程序在這個地方可能會有潛在風險接校,最好去修復一下這些出現(xiàn)警告的地方猛频。對應級別 warn,比 info 高一級蛛勉。
  • Log.e()伦乔。用于打印程序中的錯誤信息,比如程序進入了 catch 語句中董习。當有錯誤信息打印出來的時候烈和,一般代表你的程序出現(xiàn)嚴重問題了招刹,必須盡快修復窝趣。對應級別 error,比 warn 高一級妇拯。

有了這幾個方法洗鸵,已經(jīng)足夠應付絕大多數(shù)的開發(fā)場景了。但如果想要在項目中使用還遠遠不夠甘凭。

一火邓、android 為我們提供的 Log

不論是日常開發(fā)調(diào)試德撬,還是用戶行為分析蜓洪,我們都需要打印日志坯苹。android 也為我們提供了 Log 類,只需要調(diào)用 Log.d 就可以將 debug 日志打印到控制臺刚操,非常方便再芋。

Log 類中一共提供了 vd鉴逞、i司训、we勾徽、wtf 六個常用方法统扳,分別對應 verbosedebug咒钟、info朱嘴、warningerror乌昔、what a terrible failure 六種級別的日志帚湘,重要程度由低到高。

觀察 Log 類的源碼可以發(fā)現(xiàn)捅厂,這六個方法都會調(diào)用 println 方法:

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

其中资柔,priority 就代表日志的級別,這個參數(shù)是以下六個常量之一:

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 類還是非常簡單好用的羹与。但在實際工作中,僅把日志輸出到控制臺往往是不夠的吃衅,我們還需要將線上的日志記錄到文件中腾誉,以便于分析線上發(fā)生的異常。

封裝 LogUtil趣效,實現(xiàn)打印日志到文件

想要實現(xiàn)將日志輸出到文件猪贪,我們只需要做個簡單的封裝就可以了:

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")
        }
    }
}

可以看到,在調(diào)用 LogUtil.log 方法時西傀,首先調(diào)用 Log.println 方法將其輸出到控制臺池凄,然后創(chuàng)建 logFile 文件鬼廓,使用 BufferedWriter 將其寫入到文件中。

這樣的封裝很直觀碎税,那么還有什么問題嗎?

職責分離

現(xiàn)在的 LogUtil 做了兩件事:一是打印日志到控制臺伟端,二是打印日志到文件匪煌。這已經(jīng)違反了設計的單一職責原則党巾。不過看起來還好齿拂,這個類還不至于復雜到需要重構肴敛。

這時我們有了新的需求:在線上日志中,我們要重點關注 debug 級別以上的日志砸狞,如果程序運行時打印出了 debug 級別以上的日志镀梭,我們需要立即將其上傳到服務器上。

為了實現(xiàn)這個需求撒强,我們需要修改 LogUtil 類:

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

這時笙什,LogUtil 類做了三件事,并且這三件事是完全獨立的芽隆,代碼開始呈現(xiàn)出“壞味道”洞辣。

所以愁憔,我們可以對這個類進行重構,將這個類拆分出三個獨立的 LogUtil吨掌,每個 LogUtil 只負責做一件事。

先定義統(tǒng)一的接口:

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 類添加進來肛著,當調(diào)用 LogUtils.log 時跺讯,就會依次調(diào)用所有的 LogUtil 類抬吟,這樣就完成了職責分離统抬。

自動解析 tag

在打印日志時聪建,通常我們使用的 tag 都是當前類的類名茫陆,常見的寫法在類中定義一個 TAG 變量:

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

實際上在代碼運行時,我們可以自動解析出當前類的類名挥下,這樣就可以節(jié)省一個 tag 變量桨醋。

如何自動解析當前類的類名呢?我們知道偎蘸,在應用 crash 時,拋出的異常會帶有當前調(diào)用棧的信息迷雪。我們可以就從這里入手章咧,從 Throwable 中獲取到當前調(diào)用棧能真,從棧中找出當前類的類名。

我們在 LogUtils.log 方法中舟陆,調(diào)用 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)

可以看到踱承,調(diào)用棧中 LogUtils 下一個類就是調(diào)用 LogUtils 的類哨免,也就是我們需要的類琢唾,我們可以通過這個信息實現(xiàn)自動解析 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 中丘损,第一個不為當前類名的路徑,就是調(diào)用 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垢夹,我們只需要將其以 . 號分割出最后一個字符串即可维费。但匿名內(nèi)部類的 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
}

這樣就實現(xiàn)了自動解析 tag 的功能簇捍。

定位代碼行數(shù)垦写,點擊自動跳轉到調(diào)用處

在觀察 Throwable 的調(diào)用棧時彰触,我們發(fā)現(xiàn) Android Studio 有一個非常好用的功能况毅,那就是調(diào)用路徑是可點擊的尔艇,點一下就能自動跳轉到對應的代碼位置。

點擊代碼行數(shù)自動跳轉

那么這個功能是怎么實現(xiàn)的呢味廊?我們自己打印的 Log 能實現(xiàn)這樣的功能嗎余佛?

實際上這個功能實現(xiàn)非常簡單窍荧,我們不妨在 MainActivity.kt 文件中,打印這樣一條普通的日志:

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

運行程序蕊退,在 Logcat 控制臺查看這條日志瓤荔,就會發(fā)現(xiàn)它打印出來是藍色的,并且可以點擊自動跳轉到 MainActivity.kt 文件的第 10 行输硝。

實現(xiàn)點擊代碼函數(shù)自動跳轉

也就是說,這個自動跳轉的功能是 Android Studio 為我們自動封裝好的作烟,我們需要做的就是把文件名字拿撩、代碼行數(shù)找到如蚜,并按照 (文件名:代碼行數(shù)) 的格式打印日志就可以了。

那么如何找到代碼行數(shù)呢探赫?其實這個信息在 Throwable 的 stackTrace 里面已經(jīng)保存好了伦吠。我們只需要將其取出來就行了。

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})"
}

這里筆者不僅打印了代碼行數(shù)毛仪,順帶把記錄方法名的 methodName 也打印了出來箱靴。

打印當前線程名

在多線程運行時衡怀,我們有時候需要知道當前線程的名字安疗,以及其是否是主線程。所以我們可以在打印日志時蝶桶,將線程信息也打印出來真竖,便于日后分析:

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

有的讀者可能會有疑問恢共,主線程的名字都是 "main",直接從線程名字就能看出是否是主線程了讨韭,還需要判斷 Looper 嗎透硝?

這是因為子線程也可以被手動命名成 "main",所以使用 Looper 判斷會更加準確埋泵。

還能做什么丽声?

試想這樣一個場景,我們寫了一個倉庫類雁社,這個類中有一個 save 方法和一個 delete 方法晒骇,分別用于存儲和刪除數(shù)據(jù)

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

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

為了便于追蹤倉庫的修改情況厉碟,我們在這兩個方法中都打印了日志箍鼓。

這樣打印出來的日志款咖,代碼行數(shù)始終定位在 Repository 中奄喂,對我們分析日志幫助不大跨新。實際上我們更需要知道的是誰在調(diào)用這兩個方法。

當然赘被,我們可以在調(diào)用處打印日志解決這個問題民假。但如果在這兩個方法中打印日志時龙优,可以直接定位到調(diào)用這兩個函數(shù)的位置,豈不是更加方便易迹?

通過前文的調(diào)用棧分析睹欲,我們發(fā)現(xiàn)這是完全可行的句伶,只要我們在尋找調(diào)用位置時陆淀,再往棧中多找?guī)撞郊纯伞?/p>

我們用一個 stackOffset 參數(shù)來實現(xiàn)此功能。

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 中尋找調(diào)用位置時身冬,找到調(diào)用 LogUtils 的路徑后酥筝,繼續(xù)往前尋找 stackOffset 步嘿歌。比如 stackOffset 傳入 1茁影,就能找到調(diào)用"調(diào)用 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()
  }
}

調(diào)用 Timber 的某個方法時,Timber 就會依次調(diào)用其包含的日志工具類煞茫,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,并且這個長度包含了日志中的時間等信息会涎。所以如果輸出的日志內(nèi)容過長,我們需要將其裁剪后练慕,分段輸出项鬼。

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 尋找三方庫,而大佬在缺少工具類時慌核,就自己手寫一個三方庫。再次感到世界的參差...

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市灭将,隨后出現(xiàn)的幾起案子庙曙,更是在濱河造成了極大的恐慌,老刑警劉巖舶斧,帶你破解...
    沈念sama閱讀 216,591評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件什荣,死亡現(xiàn)場離奇詭異嗜闻,居然都是意外死亡,警方通過查閱死者的電腦和手機翠肘,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,448評論 3 392
  • 文/潘曉璐 我一進店門绪妹,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人廊移,你說我怎么就攤上這事蜂嗽∪杞遥” “怎么了?”我有些...
    開封第一講書人閱讀 162,823評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長听皿。 經(jīng)常有香客問我吗冤,道長,這世上最難降的妖魔是什么篷朵? 我笑而不...
    開封第一講書人閱讀 58,204評論 1 292
  • 正文 為了忘掉前任腮猖,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘项滑。我一直安慰自己危喉,他們只是感情好严蓖,可當我...
    茶點故事閱讀 67,228評論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪稿黍。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,190評論 1 299
  • 那天,我揣著相機與錄音,去河邊找鬼窖维。 笑死沛贪,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的媚送。 我是一名探鬼主播拿霉,決...
    沈念sama閱讀 40,078評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼沪铭,長吁一口氣:“原來是場噩夢啊……” “哼椰憋!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 38,923評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體仰禽,經(jīng)...
    沈念sama閱讀 45,334評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,550評論 2 333
  • 正文 我和宋清朗相戀三年凤藏,在試婚紗的時候發(fā)現(xiàn)自己被綠了揖庄。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片腌歉。...
    茶點故事閱讀 39,727評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡馍驯,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情对省,我是刑警寧澤仓手,帶...
    沈念sama閱讀 35,428評論 5 343
  • 正文 年R本政府宣布嗽冒,位于F島的核電站帅腌,受9級特大地震影響溺职,放射性物質(zhì)發(fā)生泄漏规婆。R本人自食惡果不足惜浊伙,卻給世界環(huán)境...
    茶點故事閱讀 41,022評論 3 326
  • 文/蒙蒙 一吧黄、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧唆姐,春花似錦拗慨、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,672評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽剧蹂。三九已至,卻和暖如春烦却,著一層夾襖步出監(jiān)牢的瞬間宠叼,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,826評論 1 269
  • 我被黑心中介騙來泰國打工其爵, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留冒冬,地道東北人。 一個月前我還...
    沈念sama閱讀 47,734評論 2 368
  • 正文 我出身青樓摩渺,卻偏偏與公主長得像简烤,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子摇幻,可洞房花燭夜當晚...
    茶點故事閱讀 44,619評論 2 354

推薦閱讀更多精彩內(nèi)容