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
类中一共提供了 v
、d
、i
、w
、e
、wtf
六个常用方法,分别对应 verbose
、debug
、info
、warning
、error
、what 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 寻找三方库,而大佬在缺少工具类时,就自己手写一个三方库。再次感到世界的参差...