好用的 Android 日志工具
文章目录
简介
分享一个 Android 日志工具(Java 层),几乎我的每个项目都会用到,自认为非常好用,这里描述一下它的设计和实现。
它有如下几个特点:
- 简单,仅由一个 100 余行的 Java 类实现,猴子都能看懂 _;
- 额外可选日志内容,提供线程名信息和调用栈,提供当前日志打印所在类以及所在代码行数;
- 方便,包含栈信息,直接用鼠标即可点击到日志打印所在行;
- 安全,保证日志字符串完全被优化掉,而不是留在代码中,下面会分析;
- 灵活,提供二次封装。
开源仓库地址在文末给出。
背景
日志是工程开发中必不可少的调试工具之一,良好的日志代码可以清晰的反映逻辑流程和关键变量的值,任何程序开发框架都会提供内置的日志类库供开发者使用。
Android SDK 也不例外,它提供了 android.util.Log
来打印日志,包含了 6 个级别的日志。
分别是:Assert
、Debug
、Error
、Info
、Verbose
、Warning
通常我们在使用 Log
时,几乎一定是需要包装的,最常见的需求就是发布 Release 包时去除所有打印的日志,防止泄露关键信息以及影响应用执行效率。
下面分析系统 Log
类中的几个我个人认为的优缺点。
优点:
- 足够简单,在临时测试时可以随手打印出日志;
- 自带相关调式信息,例如时间、进程/线程ID、应用包名,日志级别。
缺点:
- 没有应用内的日志开关,几乎一定需要封装;
- 调试信息不够丰富,例如线程以 ID 形式显示不够直观、无法知道日志调用所在代码行;
- 日志级别冗余,提供 6 个级别的日志,让人不知道该选择哪一个(我从没用过所有级别);
- 未提供日志格式化方法,只能使用字符串相加,这样会导致日志字符串无法被优化(下面会分析)。
由于系统 API 要设计的足够通用,必须要足够简单,所以不会提供那么多附加内容,那是框架需要做的。
那么下面就用自己的代码来弥补它的缺点。
设计
日志级别设计
我认为 4 个级别已经足够覆盖 Java 层逻辑的所有的情况。
-
Error 级别,使用红色标记,表示严重错误,不应该出现的、非预料中的错误。一旦出现,应用是无法正常执行的。
-
Warn 级别,使用橙色标记,表示警告,预料内的异常。例如网络异常,通常出现警告级别的异常,需要考虑替方案,例如没有网络,则从本地缓存读取。
-
Debug 级别,使用绿色标记,表示调试信息,打印关键逻辑相关的变量数值的信息。
-
Info 级别,使用蓝色标记,表示和流程相关的信息,例如执行进入了某个函数,某个服务被启动,通常不包含变量。
日志格式设计
首先分析系统日志包含的信息,如下:
2020-07-17 14:15:18 21194-21194/io.l0neman.example I/MainActivity: onResume: initViews
\ / \ / \ / / \ \ / \ /
[ 时间 ] [ 进程-线程 ID ] [ 包名 ] [ 级别 ] [ TAG ] [ 日志内容 ]
发现系统日志已经包含了相关信息,那么我们就不考虑添加这些信息了。
需要添加如下内容:
- 线程名字,了解实时的逻辑执行线程信息;
- 栈信息,当前调用栈信息,可像异常抛出时可用鼠标点击跳转至调用处。
例如,异常调用栈后面表示代码具体调用行,可以用鼠标定位到具体位置:
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 个字符,避免影响搜索效率,所以不考虑在这里添加过多内容。
通常最关心的还是日志的内容,所以添加的线程信息和栈信息不考虑放在日志最前面,那么日志格式设计如下。
${log content} [thread name](Class:${line number})
对应实际日志为:
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 之间使用 # 相连,格式如下。
Primary#Secondary
那么,当应用简单时,只有一个应用模块,主 TAG 就可以表示整个应用,例如程序为 LoggerExample。
那么可以使用 LE
作为主 TAG,用于和其他应用区分,每个类的名字作为子 TAG,用于表示具体类的功能标签:
LE#MainActivity
当应用复杂,具有多个模块,那么,主 TAG 就可以表示每个模块,例如模块为 Foo,Bar。
每个类的名字依然作为子 TAG,打印日志的时候就可以区分模块了:
Foo#FileManager
Bar#ImageFactory
至于更细粒度的 Java 方法,TAG 并不是必须的,那么可以在日志内容中进行补充。
日志安全性设计
日志的安全性体现如下:
- 日志内容不允许在 release 版本中,因为打印时会泄露关键变量信息。
- 日志内容不允许出现在 release 版本的 DEX 文件中,因为会给逆向分析者提供流程逻辑信息,通常逆向分析者通过静态分析工具将 DEX 反编译为类 Java 代码,如果看到日志中的详细信息,相当于了解了当前函数的作用。
下面是实例分析,通常封装日志时都采用如下 logD
的方法,直接使用 BuildConfig.DEBUG
变量来包装一下,打印日志时使用 +
号连接字符串内容:
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 查看这段代码,如下:
...
public final void r() {
"#testAndroidLog mId=" + this.p;
t();
}
public static void t() {
}
发现字符串组合的代码依然存在,因为当混淆器发现 logD
函数里面是空内容(由于 BuildConfig.DEBUG
为常量 false
,所以编译器在编译成字节码时直接把此句 if
语句移除了),没有用到 tag
和 log
参数,所以移除了 tag
和 log
参数,然后将 logD
函数混淆为 t
,但是在原始代码传递 log
时,做了字符串相加的运算,需要生成临时变量,则混淆器认为此句逻辑为有效逻辑,则不优化。
虽然从逻辑角度,这句代码完全可以优化掉,但是对于混淆器来说,它分析代码时需要更保守,防止优化掉有用的代码。
那么当逆向人员静态分析时,一下就能看出这个函数的用意是 testAndroidLog
,即为了测试 Android Log,且 mId
变量和此方法紧密想关,当大量此类型的日志留在代码中,会对逆向人员提供较大帮助。
那么效果最好的一定是将 if
语句放在外面:
private void testAndroidLog() {
if (BuildConfig.DEBUG) {
logD(TAG, "#testAndroidLog mId=" + mId);
}
}
在编译时就全部优化掉了,然而每打印一次日志都得写,非常麻烦,所以不考虑。
那么最终采用如下方案,使用字符串格式化的方式解决此问题。
private void testLogger() {
loggerD(TAG, "#testAndroidLog mId=%d", mId);
}