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
類中一共提供了 v
、d
鉴逞、i
司训、w
、e
勾徽、wtf
六個常用方法统扳,分別對應 verbose
、debug
咒钟、info
朱嘴、warning
、error
乌昔、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)用路徑是可點擊的尔艇,點一下就能自動跳轉到對應的代碼位置。
那么這個功能是怎么實現(xiàn)的呢味廊?我們自己打印的 Log 能實現(xiàn)這樣的功能嗎余佛?
實際上這個功能實現(xiàn)非常簡單窍荧,我們不妨在 MainActivity.kt 文件中,打印這樣一條普通的日志:
Log.d("~~~", "(MainActivity.kt:10)")
運行程序蕊退,在 Logcat 控制臺查看這條日志瓤荔,就會發(fā)現(xiàn)它打印出來是藍色的,并且可以點擊自動跳轉到 MainActivity.kt 文件的第 10 行输硝。
也就是說,這個自動跳轉的功能是 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 尋找三方庫,而大佬在缺少工具類時慌核,就自己手寫一個三方庫。再次感到世界的參差...