作者简介:ASCE1885, 《Android 高级进阶》作者。
本文由于潜在的商业目的,未经授权不开放全文转载许可,谢谢!
本文分析的源码版本已经 fork 到我的 Github。
logger 可以说是 Android 平台最著名的日志框架,从一出现就吸引了广大开发者的关注。演化至今,logger 框架具备的能力主要有:
- 以类似表格的方式展示日志,视觉优美,能够清晰的分隔不同的日志记录
- 全面的信息展示,支持线程信息,函数调用栈信息的展示
- 支持点击跳转到记录日志源码的位置(Android Studio 提供的能力)
- 支持 JSON,XML,List,Map 和 Set 的格式化输出
- 默认支持 Logcat 和文件两种日志记录输出方式,并提供动态可扩展的能力
logger 日志记录在 Logcat 中的效果和说明如下图所示:
在最初的几个版本中,logger 框架只有两个类:Logger 和 LogLevel,前者是日志记录的核心实现,后者是日志级别定义。功能上只支持打印日志到 Logcat 中,不支持记录日志到文件,架构设计上也比较简单,扩展性不强。随着多个版本的迭代,logger 框架以面向接口编程的方式优化了整体的架构设计,可扩展性更强。本文写作时 2.1.1 版本的类结构图如下所示:
可以看到,整体还是很清晰的,有四个接口:
- LogAdapter:日志的整体输出方式的适配器
- FormatStrategy:日志输出格式的策略定义
- LogStrategy:日志如何输出的策略定义
- Printer:对框架使用者而言的方法定义
而 Logger 类是作为对外的核心类存在的。
架构设计
logger 的整体架构和生命周期官网已经给出来,如下图所示,可以看到,有五层的调用关系:
其中 Logger
是对外的接口类,开发者调用这个类提供的方法来将需要记录的日志信息传递给 logger 框架,而 Logger
会将调用委托给 Printer
接口的实现类 LoggerPrinter
,实际的日志记录行为都是 LoggerPrinter
负责的。LoggerPrinter
提供了一个方法可以添加 LogAdapter
的实例列表,从图中我们也可以看出来,这样开发者只需调用一个方法就可以同时实现多种类型的日志记录方式。LogAdapter
正是为了实现多种方式日志记录而实现的适配器接口,logger 默认提供了 AndroidLogAdapter
和 DiskLogAdapter
这两种适配器,分别用来实现 Logcat 日志记录和文件日志记录。同时日志记录会有不同的格式需求,这是通过 FormatStrategy
接口实现的,logger 中默认实现了两种格式策略,一种是 PrettyFormatStrategy
,另一种是 CsvFormatStrategy
,分别适用于 Logcat 日志记录和文件日志记录,主要是起到美观和易读的目的。最后 FormatStrategy
实现类会再调用一个日志记录策略类 LogStrategy
来决定是把日志打印到 Logcat 还是记录到文件中。
实现细节
下面我们就按照接口调用层级从最底层依次往上剖析,首先来看下 LogStrategy 接口及其实现类。
LogStrategy
LogStrategy 接口定义了日志如何输出的策略,从名字可以看出,是应用了策略模式,它的用意是针对一组算法,将每个算法封装起来,让它们实现一个共同的接口,使它们可以相互替换。策略模式可以实现算法在不影响使用者的情况下发生变化。试想一下,如果不使用策略模式,那么我们要实现运行时灵活的根据具体条件切换具体的算法,那么是不是只能通过 if...else 等类似方式来实现?也就是把所有具体算法的选择都封装在了一个类中,在需要新增或者删除某个算法时,都需要到这个类中进行修改。而策略模式,则屏蔽了内部的修改,一切由使用者来选择,符合开放-封闭原则。
由于 FormatStrategy 也是使用的策略模式,同时它的实现类中实例化了 LogStrategy 的实现类,因此类结构图结合在一起看:
LogStrategy 接口的定义很简单,只有 log 这个方法:
public interface LogStrategy {
/**
* log 方法
* @param priority 优先级
* @param tag 标签
* @param message 日志信息
*/
void log(int priority, String tag, String message);
}
然后由具体的策略类实现这个 log 方法,在其中实现各自的日志记录算法,具体到 LogcatLogStrategy 类,它的目的是实现将日志打印到 Android Logcat 中,因此它的算法实现很简单,直接调用 Android Log 的 println 方法即可,如下所示:
public class LogcatLogStrategy implements LogStrategy {
@Override public void log(int priority, String tag, String message) {
Log.println(priority, tag, message);
}
}
而另一个子类 DiskLogStrategy 实现则稍微复杂一点,因为涉及到要将日志信息写入文件,所以需要在子线程中实现文件写入操作,具体是使用 Handler 来处理,如下所示:
public class DiskLogStrategy implements LogStrategy {
private final Handler handler;
public DiskLogStrategy(Handler handler) {
this.handler = handler;
}
@Override public void log(int level, String tag, String message) {
// 因为我们不能控制调用者是在主线程还是子线程,因此这里直接将消息抛到 handler 中处理(毕竟是写文件)
handler.sendMessage(handler.obtainMessage(level, message));
}
}
可以看到, Handler 以构造函数依赖注入的方式传给 DiskLogStrategy,保证良好的扩展性,同时给出了一个默认的 Handler 实现 WriteHandler,它以 DiskLogStrategy 的静态内部类的形式存在。WriteHandler 是 Handler 的子类,熟悉 Handler 的我们肯定知道它的核心处理逻辑应该在 handleMessage 方法中。核心代码如下所示:
@Override public void handleMessage(Message msg) {
String content = (String) msg.obj;
FileWriter fileWriter = null;
File logFile = getLogFile(folder, "logs");
try {
fileWriter = new FileWriter(logFile, true);
writeLog(fileWriter, content);
fileWriter.flush();
fileWriter.close();
} catch (IOException e) {
if (fileWriter != null) {
try {
fileWriter.flush();
fileWriter.close();
} catch (IOException e1) { /* fail silently */ }
}
}
}
可以看到,日志信息写文件很简单,可以分为两步:
- 第一步,创建日志文件,这一步在 getLogFile 方法中实现,我们直接上代码,相关注释也很详细,这里需要注意,我们的日志文件后缀名是 .csv,这个后面会进一步介绍。
/**
* 每记录一条日志都会调用一次,因此一般建议在测试环境中使用,线上版本应该关闭文件日志记录
* @param folderName 日志文件目录名
* @param fileName 日志文件名
* @return
*/
private File getLogFile(String folderName, String fileName) {
// 如果文件夹不存在,则先创建
File folder = new File(folderName);
if (!folder.exists()) {
folder.mkdirs();
}
// 每个日志文件有大小限制,由使用者来指定,每一条新的日志信息都会记录到
// 当前还没超出大小限制的最新一个文件中,newFileCount 用来递增文件名
int newFileCount = 0;
File newFile;
File existingFile = null;
// 遍历日志目录中已经存在的日志文件,找到最新的那个文件并放在 existingFile 变量中
newFile = new File(folder, String.format("%s_%s.csv", fileName, newFileCount));
while (newFile.exists()) {
existingFile = newFile;
newFileCount++;
// 创建一个新的日志文件,需要注意这只是在内存中创建 File 文件映射对象,此刻并不会在硬盘中创建实际文件
// 除非你往这个文件中写入数据,或者调用 newFile.createNewFile() 创建真实文件
newFile = new File(folder, String.format("%s_%s.csv", fileName, newFileCount));
}
if (existingFile != null) {
// 如果最新的日志文件 existingFile 超出大小限制,则使用新创建的 newFile 文件
if (existingFile.length() >= maxFileSize) {
return newFile;
}
return existingFile;
}
return newFile;
}
- 第二步,使用 FileWriter 将日志信息写入文件,FileWriter 类从 OutputStreamReader 类继承而来,主要实现按字符向流中写入数据,我们的日志信息是文本形式存在,因此选择 FileWriter 是理所当然的。具体用法可以参见 Java SE 相关图书,从上面代码也可以看出它的基本用法。
FormatStrategy
通过前面的介绍,我们对 FormatStrategy 已经不陌生,它也是使用的策略模式,定义了日志输出格式的策略,接口代码如下所示,和 LogStrategy 定义其实是一样的,只不过两者目的不同。
public interface FormatStrategy {
void log(int priority, String tag, String message);
}
FormatStrategy 的两个子类 CsvFormatStrategy 和 PrettyFormatStrategy 分别实现 csv 格式输出和 的本文开头所看到的那种漂亮的类表格化格式输出算法。
CsvFormatStrategy
首先,为了让不熟悉 csv 的读者有个基本的了解,我们先介绍下 csv 的基本知识。csv 是一种文件格式,全称是 Comma-Separated Values,也就是逗号分隔值文件,它是一种文本文件,它的文件格式有如下限定:
- 每条记录占一行
- 以逗号为分隔符
- 逗号前后的空格会被忽略
- 字段中包含有逗号,那么该字段必须用双引号括起来
- ......
更详细的介绍可以参见《The Comma Separated Value (CSV) File Format》。一个 csv 文件内容示例如下所示:
John,Doe,120 jefferson st.,Riverside, NJ, 08075
Jack,McGinnis,220 hobo Av.,Phila, PA,09119
"John ""Da Man""",Repici,120 Jefferson St.,Riverside, NJ,08075
Stephen,Tyler,"7452 Terrace ""At the Plaza"" road",SomeTown,SD, 91234
,Blankman,,SomeTown, SD, 00298
"Joan ""the bone"", Anne",Jet,"9th, at Terrace plc",Desert City,CO,00123
具体到 CsvFormatStrategy,它的 csv 文件格式中每条记录的组成如下:
epoch 时间戳(机器可读的), ISO8601 时间戳 (人类可读的), 日志级别, 日志标签, 日志信息
CsvFormatStrategy 类的可选入参有四个,分别是:
private final Date date; // 日期
private final SimpleDateFormat dateFormat; // 日期的格式
private final LogStrategy logStrategy; // LogStrategy 实例
private final String tag; // 日志标签
算是比较多的,为了减少对象创建过程中引入多个重载的构造函数,或者避免 setters 方法的过度使用,因此使用 Builder 模式来进行实例的初始化,如果读者对该模式还不熟悉,可以阅读《Android 高级进阶》一书的《Builder 模式详解》一节。这些参数都是可选的,如果调用者没有传入的话,会有默认值,这是在 Builder 类的 build 方法中实现的: