好用的 Android 日志工具

简介

分享一个 Android 日志工具(Java 层),几乎我的每个项目都会用到,自认为非常好用,这里描述一下它的设计和实现。

它有如下几个特点:

  1. 简单,仅由一个 100 余行的 Java 类实现,猴子都能看懂 ^_^;
  2. 额外可选日志内容,提供线程名信息和调用栈,提供当前日志打印所在类以及所在代码行数;
  3. 方便,包含栈信息,直接用鼠标即可点击到日志打印所在行;
  4. 安全,保证日志字符串完全被优化掉,而不是留在代码中,下面会分析;
  5. 灵活,提供二次封装。

开源仓库地址在文末给出。

背景

日志是工程开发中必不可少的调试工具之一,良好的日志代码可以清晰的反映逻辑流程和关键变量的值,任何程序开发框架都会提供内置的日志类库供开发者使用。

Android SDK 也不例外,它提供了 android.util.Log 来打印日志,包含了 6 个级别的日志。

分别是:AssertDebugErrorInfoVerboseWarning

通常我们在使用 Log 时,几乎一定是需要包装的,最常见的需求就是发布 Release 包时去除所有打印的日志,防止泄露关键信息以及影响应用执行效率。

下面分析系统 Log 类中的几个我个人认为的优缺点。

优点:

  1. 足够简单,在临时测试时可以随手打印出日志;
  2. 自带相关调式信息,例如时间、进程/线程ID、应用包名,日志级别。

缺点:

  1. 没有应用内的日志开关,几乎一定需要封装;
  2. 调试信息不够丰富,例如线程以 ID 形式显示不够直观、无法知道日志调用所在代码行;
  3. 日志级别冗余,提供 6 个级别的日志,让人不知道该选择哪一个(我从没用过所有级别);
  4. 未提供日志格式化方法,只能使用字符串相加,这样会导致日志字符串无法被优化(下面会分析)。

由于系统 API 要设计的足够通用,必须要足够简单,所以不会提供那么多附加内容,那是框架需要做的。

那么下面就用自己的代码来弥补它的缺点。

设计

日志级别设计

我认为 4 个级别已经足够覆盖 Java 层逻辑的所有的情况。

  1. Error 级别,使用红色标记,表示严重错误,不应该出现的、非预料中的错误。一旦出现,应用是无法正常执行的。

  2. Warn 级别,使用橙色标记,表示警告,预料内的异常。例如网络异常,通常出现警告级别的异常,需要考虑替方案,例如没有网络,则从本地缓存读取。

  3. Debug 级别,使用绿色标记,表示调试信息,打印关键逻辑相关的变量数值的信息。

  4. Info 级别,使用蓝色标记,表示和流程相关的信息,例如执行进入了某个函数,某个服务被启动,通常不包含变量。

日志格式设计

首先分析系统日志包含的信息,如下:

1
2
3
2020-07-17 14:15:18 21194-21194/io.l0neman.example I/MainActivity: onResume: initViews 
\ / \ / \ / / \ \ / \ /
[ 时间 ] [ 进程-线程 ID ] [ 包名 ] [ 级别 ] [ TAG ] [ 日志内容 ]

发现系统日志已经包含了相关信息,那么我们就不考虑添加这些信息了。

需要添加如下内容:

  1. 线程名字,了解实时的逻辑执行线程信息;
  2. 栈信息,当前调用栈信息,可像异常抛出时可用鼠标点击跳转至调用处。

例如,异常调用栈后面表示代码具体调用行,可以用鼠标定位到具体位置:

1
2
3
4
Caused by: java.lang.RuntimeException: demo
at io.l0neman.example.MainActivity.onCreate(MainActivity.java:17)
at android.app.Activity.performCreate(Activity.java:7802)
...

通常我们可以定制的部分只有日志的 TAG 和后面的内容。

TAG 官方建议不超过 20 个字符,避免影响搜索效率,所以不考虑在这里添加过多内容。

通常最关心的还是日志的内容,所以添加的线程信息和栈信息不考虑放在日志最前面,那么日志格式设计如下。

1
${log content} [thread name](Class:${line number})

对应实际日志为:

1
2020-07-17 14:15:18 21194-21194/io.l0neman.example I/MainActivity: onResume: initViews [main](MainActivity.java:24)

这样就设计好了日志格式,继续其他部分的设计。

日志 TAG 设计

TAG 是一个标签用于快速搜索,通常为一个类名或一个模块的标签,便于识别日志所处模块环境。

Android 系统源代码中通常一个类表示一项功能,那么此类中都有一个 TAG 常量,值和类名相同;

当项目复杂时,包含多个模块,每个模块包含多个类,一个类表示一个功能,那么我认为 TAG 需要分层。

由于 TAG 不宜过长,那就设计两层,如下。

TAG 分为一个主 TAG 和子 TAG,两个 TAG 之间使用 # 相连,格式如下。

1
Primary#Secondary

那么,当应用简单时,只有一个应用模块,主 TAG 就可以表示整个应用,例如程序为 LoggerExample。

那么可以使用 LE 作为主 TAG,用于和其他应用区分,每个类的名字作为子 TAG,用于表示具体类的功能标签:

1
LE#MainActivity

当应用复杂,具有多个模块,那么,主 TAG 就可以表示每个模块,例如模块为 Foo,Bar。

每个类的名字依然作为子 TAG,打印日志的时候就可以区分模块了:

1
2
3
Foo#FileManager

Bar#ImageFactory

至于更细粒度的 Java 方法,TAG 并不是必须的,那么可以在日志内容中进行补充。

日志安全性设计

日志的安全性体现如下:

  1. 日志内容不允许在 release 版本中,因为打印时会泄露关键变量信息。
  2. 日志内容不允许出现在 release 版本的 DEX 文件中,因为会给逆向分析者提供流程逻辑信息,通常逆向分析者通过静态分析工具将 DEX 反编译为类 Java 代码,如果看到日志中的详细信息,相当于了解了当前函数的作用。

下面是实例分析,通常封装日志时都采用如下 logD 的方法,直接使用 BuildConfig.DEBUG 变量来包装一下,打印日志时使用 + 号连接字符串内容:

1
2
3
4
5
6
7
8
9
private void testAndroidLog() {
logD(TAG, "#testAndroidLog mId=" + mId);
}

private static void logD(String tag, String log) {
if (BuildConfig.DEBUG) {
Log.d(TAG, log);
}
}

如果在 Debug 版本调用 testAndroidLog 方法,将会正常打印日志,在 Release 中则 logcat 中看不到任何日志。

通常在打包 Release 版本时,会打开压缩选项 minifyEnabled true,混淆器会对未使用的参数进行优化移除。

那么使用静态反编译器 jadx 打开 APK 查看这段代码,如下:

1
2
3
4
5
6
7
8
...
public final void r() {
"#testAndroidLog mId=" + this.p;
t();
}

public static void t() {
}

发现字符串组合的代码依然存在,因为当混淆器发现 logD 函数里面是空内容(由于 BuildConfig.DEBUG 为常量 false,所以编译器在编译成字节码时直接把此句 if 语句移除了),没有用到 taglog 参数,所以移除了 taglog 参数,然后将 logD 函数混淆为 t,但是在原始代码传递 log 时,做了字符串相加的运算,需要生成临时变量,则混淆器认为此句逻辑为有效逻辑,则不优化。

虽然从逻辑角度,这句代码完全可以优化掉,但是对于混淆器来说,它分析代码时需要更保守,防止优化掉有用的代码。

那么当逆向人员静态分析时,一下就能看出这个函数的用意是 testAndroidLog,即为了测试 Android Log,且 mId 变量和此方法紧密想关,当大量此类型的日志留在代码中,会对逆向人员提供较大帮助。

那么效果最好的一定是将 if 语句放在外面:

1
2
3
4
5
private void testAndroidLog() {
if (BuildConfig.DEBUG) {
logD(TAG, "#testAndroidLog mId=" + mId);
}
}

在编译时就全部优化掉了,然而每打印一次日志都得写,非常麻烦,所以不考虑。

那么最终采用如下方案,使用字符串格式化的方式解决此问题。

1
2
3
4
5
6
7
8
9
private void testLogger() {
loggerD(TAG, "#testAndroidLog mId=%d", mId);
}

private static void loggerD(String tag, String formant, Object... args) {
if (BuildConfig.DEBUG) {
Log.d(TAG, String.format(formant, args));
}
}

使用 jadx 静态反编译器查看得到:

1
2
3
4
5
6
7
8
...
public final void s() {
new Object[1][0] = Integer.valueOf(this.p);
u();
}

public static void u() {
}

这样的话,由于每个参数都是分别传递的,除了后面的不定参数列表需要创建临时数组,所以混淆器会直接把独立的参数优化掉,那么就看不到前面的格式化字符串参数了。上面的代码根本看不出来任何逻辑,保证了应用逻辑的安全性。

实现

根据上面的分析和设计,开始具体实现。

类名

类名命名为很普通的 Logger

API

首先提供日志 API,包含四个级别的打印,均为静态方法。

PRINT 为全局打印标记,通常设置为 BuildConfig.DEBUG,可根据需要修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// Logger.java

private static final boolean PRINT = BuildConfig.DEBUG;

public static void i(String tag, String format, Object... args) {
if (PRINT) {
Log.i(tag, String.format(format, args));
}
}

public static void i(String tag, Throwable tr, String format, Object... args) {
if (PRINT) {
Log.i(tag, String.format(format, args), tr);
}
}

public static void d(String tag, String format, Object... args) {
if (PRINT) {
Log.d(tag, String.format(format, args));
}
}

public static void d(String tag, Throwable tr, String format, Object... args) {
if (PRINT) {
Log.d(tag, String.format(format, args), tr);
}
}

public static void w(String tag, String format, Object... args) {
if (PRINT) {
Log.w(tag, String.format(format, args));
}
}

public static void w(String tag, Throwable tr, String format, Object... args) {
if (PRINT) {
Log.w(tag, String.format(format, args), tr);
}
}

public static void w(String tag, Throwable tr) {
if (PRINT) {
Log.w(tag, tr);
}
}

public static void e(String tag, String format, Object... args) {
if (PRINT) {
Log.e(tag, String.format(format, args));
}
}

public static void e(String tag, Throwable tr, String format, Object... args) {
if (PRINT) {
Log.e(tag, String.format(format, args), tr);
}
}

日志 TAG

日志 TAG 由处理参数 tag 得到,根据前面的设计,那么 tag 参数为子 TAG,主 TAG 定为常量,根据项目不同,对其进行修改。

对于每个模块的打印,参考后面使用方法中的用法。

1
2
3
4
5
6
7
8
// Logger.java

// 这里代表 LoggerExample 应用,使用时根据应用或模块更改主 TAG
private static final String MAIN_TAG = "LE";

private static String getTag(String tag) {
return MAIN_TAG + "#" + tag;
}

此时把 API 中的 tag 全部替换为 getTag(tag) 即可,例如:

1
2
3
4
5
6
7
// Logger.java

public static void d(String tag, String format, Object... args) {
if (PRINT) {
Log.d(getTag(tag), String.format(format, args));
}
}

日志内容

日志内容,根据设计部分提到的格式,对于原始日志内容添加上线程信息和栈信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Logger.java

// 提供栈信息关闭选项。
private static final boolean STACK_PRINT = true;

private static String getThreadName() {
return Thread.currentThread().getName();
}

private static String getLog(String log) {
String stackInfo = STACK_PRINT ? stackInfo() : "";
return log + " [" + getThreadName() + ']' + stackInfo;
}

线程信息直接使用 Thread.currentThread().getName() 获取即可。

栈信息,单独说一下,当前的调用栈信息可使用 Thread.currentThread().getStrackTrace() 获取,它返回一个数组,包含从当前调用代码行,向上追溯的多层栈信息,就和下面的异常类似:

1
2
3
4
Caused by: java.lang.RuntimeException: demo
at io.l0neman.example.MainActivity.onCreate(MainActivity.java:17)
at android.app.Activity.performCreate(Activity.java:7802)
...

那么此时,需要获得日志调用行的相关栈信息,就需要从这个数组里面找出来,通过类命比较即可快速找到数组的索引。

日志需要考虑三种栈信息的支持:

  1. 当前日志调用行的栈信息,鼠标点击可跳转;
  2. 提供打印多层栈信息的选项,栈信息层数用户可以自定义;
  3. 提供对日志方法的封装,那么此时当前调用行的栈信息应该修正为用户的封装方法的当前调用行。

最终 stackInfo() 方法的实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Logger.java

// 打印栈的层数,支持上面的 2

private static final int STACK_COUNT = 1;
// 栈寻找的起始深度
private static final int STACK_DEPTH = 1;
// 栈偏移,基于标准日志方法的偏移,调整偏移可支持上面的 3
private static final int STACK_OFFSET = 4;

// 组成当前日志调用行的栈信息,例如 (MainActivity.java:20),鼠标点击可跳转
private static String getFormatStackInfo(StackTraceElement element) {
return "(" + element.getFileName() + ':' + element.getLineNumber() + ')';
}

// 找到 `Logger` 的栈偏移。
private static int getStackDepth(StackTraceElement[] elements) {
for (int i = STACK_DEPTH; i < elements.length; i++) {
final StackTraceElement element = elements[i];
if (!element.getClassName().split("\\$")[0].equals(Logger.class.getName())) {
return i;
}
}

return -1;
}

private static String stackInfo() {
final StackTraceElement[] elements = Thread.currentThread().getStackTrace();
final int stackDepth = getStackDepth(elements);
int bottomStackDepth = stackDepth + STACK_OFFSET;
int topStackDepth = stackDepth + STACK_COUNT + STACK_OFFSET;
if (bottomStackDepth >= elements.length) {
bottomStackDepth = elements.length - 1;
}

if (topStackDepth > elements.length) {
topStackDepth = elements.length;
}

StringBuilder builder = new StringBuilder();
StringBuilder stackTable = new StringBuilder();
for (int i = topStackDepth - 1; i >= bottomStackDepth; i--) {
final String formatStackInfo = getFormatStackInfo(elements[i]);
builder.append(stackTable).append(formatStackInfo).append('\n');
stackTable.append(" ");
}

return builder.toString();
}

具体逻辑不再详细分析了,还需要自己去阅读。

最后将 API 的 log 参数替换成 getLog(log),例如:

1
2
3
4
5
6
7
// Logger.java

public static void d(String tag, String format, Object... args) {
if (PRINT) {
Log.d(getTag(tag), String.format(format, args));
}
}

此时就完成了所有逻辑,虽然简单,但是用起来很舒服,需要自己去亲自体验。

封装支持

首先使用 Logger 打印一个普通的日志:

1
2
3
4
// MainActivity.java

// line number: 26
Logger.d(TAG, "#testAndroidLog mId=%d", mId);

结果如下:

1
...io.l0neman.example D/LE#MainActivity: #testAndroidLog mId=8 [main](MainActivity.java:26)

此时,假如想要封装 Logger,如下:

1
2
3
4
5
6
7
// MainActivity.java

// line number: 30
private void myLoggerD(String tag, String log) {
// line number: 31
Logger.d(tag, log);
}

调用我们封装的 myLogger 方法打印:

1
2
3
4
// MainActivity.java

// line number: 27
myLoggerD(TAG, "hello world");

结果如下:

1
...io.l0neman.example D/LE#MainActivity: hello world [main](MainActivity.java:31)

此时发现栈信息依然是 Logger 原来原始打印语句的地方,这样肯定封装失败了,此时需要修正栈偏移。

由于封装只有一层,那么只要一层栈偏移即可对应到封装方法的调用行,使 STACK_OFFSET 增加 1 即可:

1
2
3
// 栈偏移,基于标准日志方法的偏移
- private static final int STACK_OFFSET = 4;
+ private static final int STACK_OFFSET = 5;

再次测试,栈偏移修正到了封装方法上,此时就没问题了。

1
...io.l0neman.example D/LE#MainActivity: hello world [main](MainActivity.java:27)

使用方法

单项目使用方法

对于单个应用来说,把 Logger 复制到项目包中,推荐重命名为与项目相关的有意义的名字,例如对于实例项目 LoggerExample 来说,把 Logger 类,重命名为 LeLogger

然后将主 TAG 改为应用的标识 LE 即可,然后按照需要自定义 Logger 选项。

在 logcat 中输入 LE# 即可过滤出应用的日志。

多模块项目使用方法

当一个项目存在多个模块时,推荐在每个模块中都复制进去一个 Logger 类,然后分别重命名为每个模块的标识。

例如有一个记事本有 Read Module 和 Edit Module 两个模块,那么分别复制两个 Logger 类到两个模块的包,分别重命名为 RmLoggerWmLogger,同时更改主 TAG 为 RMWM,这样过滤日志使用主 TAG 就能区分模块的日志了。

在调用时,属于两个模块的代码分别调用各自的 Logger 类,互不干扰,那么就可以各自定义自己的日志开关和选项。

Logger 选项说明

  1. Logger.PRINT = true

此标记控制所在 Logger 类的日志开关,设为 false 时,使用此 Logger 打印的所有日志将被移除。

  1. Logger.STACK_PRINT = true

此标记控制是否在日志尾部显示调用栈信息,例如 (MainActiviy.java:20)。显示后,可使用鼠标点击跳转,设为 false,将不再显示。

  1. Logger.STACK_OFFSET = 4

此标记控制打印单行调用栈信息的偏移。当需要对 Logger 进行包装时,如果包装一层,即 Logger 打印代码的上一次就是包装方法的代码时,将偏移 +1,偏移随着包装方法的层次累加而递增。

  1. Logger.STACK_COUNT = 1

此标记控制打印调用栈信息的数量,默认为 1,只显示当前日志调用行的调用栈信息。可根据需要改变栈信息数量。

设置为 2 时效果如下:

1
2
3
4
...io.l0neman.example D/LE#MainActivity: hello world [main](Activity.java:7791)
(Activity.java:7802)
(MainActivity.java:21)
(MainActivity.java:26)

设置为 7 时效果如下:

1
2
3
4
5
6
7
...io.l0neman.example D/LE#MainActivity: hello world [main](LaunchActivityItem.java:83)
(ActivityThread.java:3409)
(ActivityThread.java:3245)
(Instrumentation.java:1299)
(Activity.java:7791)
(Activity.java:7802)
(MainActivity.java:21)

仓库地址

https://github.com/l0neman/Logger

作者

l0neman

发布于

2020-07-23

更新于

2020-07-23

许可协议

评论