一个android日志库的实现

调研

在android开发中,如果需要打印调试信息,往往会使用Log.d()、Log.e()之类的由android.util.Log提供的方法。它的好处是简单、方便,不足也很明显,可定制性差,不能打印到文件。如果想在发行版app中的某些关键语句打印log到文件,或者在app崩溃时把崩溃信息打印到文件供上传分析,那么android.util.Log就无能为力了。

在网上做了一下android平台日志库的调研,挑出一些比较有代表性有影响力的,大致结果如下:

①orhanobut/logger:
来自github.com,截止2015年11月10日15:53:20该项目已经有1996颗星!!!
地址:https://github.com/orhanobut/logger

②JakeWharton/timber:
来自github.com,截止2015年11月10日15:54:22该项目已经有1571颗星!!!
地址:https://github.com/JakeWharton/timber

③noveogroup/android-logger:
来自github.com,截止2015年11月10日15:55:56该项目已经有125颗星(跟上面两位比差的挺多)。
地址:https://github.com/noveogroup/android-logger

④SLF4J:
它只是一个对log系统进行抽象的表现形式,不是具体的实现。它支持第三方log框架,比如log4j等。如果需要指定第三方log框架,只需要在运行时把对应的slf4j的jar文件放在你的class path中,系统会自动bind该log框架。比如,要从java.util.logging中切换到log4j,只需要把slf4j-jdk-1.7.12.jar替换为slf4j-log4j12-1.7.12.jar即可。
地址:http://www.slf4j.org/download.html

⑤SLF4J-Android:
SLF4J-Android是对SLF4J的API的重新包装,以及一个轻量级的bind实现。该实现简单的把所有的SLF4J的log请求都用Android的Log工具进行了替换。功能与SLF4J类似。不过只支持23个字符长度的Tag,例如com.example.myapp.MyClass会被截断为c*.e*.m*.MyClass,会导致不同的类在tag中显示同样的名称,引起混淆。
地址:http://www.slf4j.org/android/

⑥ LOGBack:
LOGBack与Log4J的作者是同一个人。作者更推荐使用LOGBack。具作者说LOGBack在某些苛刻的执行路径下,比log4j差不多要快10倍,经过了长期而广泛的测试,比log4j更为可靠。不过这货是个重量级选手,功能太全,体积庞大。
地址:http://logback.qos.ch/

⑦android-logging-log4j:
内部使用log4j,支持slf4j;适用于log4j的LogCatAppender,它能够把log打印到LogCat;提供了一个log4j的配置装饰类,用它能够方便地配置Log4J;同时不需要修改log4j.jar文件。
地址:https://code.google.com/p/android-logging-log4j/

前三个库是专为android设计的,可以看到支持者众多,不过它们内部实现上也是调用android.util.Log的方法打印到控制台,不能满足我们的需求。后面的几个库中有一些选手非常全能,不过体积较大,很多功能是我们不需要的,而且它也无法很方便的扩展自定义的日志实现,比如app崩溃信息获取、日志文件后台上传等。于是乎开始自己动手实现一个既方便实用,能覆盖基本的控制台、文件输出,又便于自定义扩展的android日志库。

gradle直接配置日志库

log4j最强大方便的地方应该就是它可以直接通过property文件进行配置。这里由于我们实用Android Studio进行开发,所以希望能够直接通过build.gradle文件对我们的日志库进行配置。在网上搜索了良久,发现可以在buildTypes中分别为debug版本和release版本(甚至更多其他自定义版本)定义不同的buildConfigField参数,然后在java代码中通过自动生成的BuildConfig文件进行读取。

例如,我在module的build.grade文件中做了如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'

//Logger settings for release mode
//以下是配置MyLog打印功能的各项参数
buildConfigField("String", "logLevel", "\"VERBOSE\"")//设置当前log的打印级别。支持的打印级别分别为:VERBOSE,DEBUG,INFO,WARN,ERROR,ASSERT。与logcat的级别一一对应。只有等于或高于此级别的log才会被打印出来。
buildConfigField("String", "logToSDDirName", "\"MyLogs-release\"")//如果设置了打印到文件,则此字段指定把日志打印到SD卡的哪个目录
buildConfigField("int", "singleLogFileSizeLimit", "10")//如果设置了打印到文件,则此字段设置单个log文件的大小上限,单位是MB
}

debug {
//Logger settings for debug mode
buildConfigField("String", "logLevel", "\"DEBUG\"")//设置当前log的打印级别。支持的打印级别分别为:VERBOSE,DEBUG,INFO,WARN,ERROR,ASSERT。与logcat的级别一一对应。只有等于或高于此级别的log才会被打印出来。
buildConfigField("String", "logToSDDirName", "\"MyLogs-debug\"")//如果设置了打印到文件,则此字段指定把日志打印到SD卡的哪个目录
buildConfigField("int", "singleLogFileSizeLimit", "10")//如果设置了打印到文件,则此字段设置单个log文件的大小上限,单位是MB
}
}

那么就可以在对应module的java代码中进行读取:

1
int a = BuildConfig.logMethodDepth;

这个方案并没有问题,不过接下来却踩进了一个坑,因为我下意识的把这些配置都写在了我的Log库module中,同样,对配置参数(如logMethodDepth等)的读取也是在Log库module的java代码中。这样就带来两个问题,也是后来才意识到的:

  • 坑①:
    Gradle目前有一个bug:你在库项目的build.gradle中定义了release和debug两种配置,然后不论你的Build Variants是选择debug还是release,默认都只会编译release版本。在stackoverflow上搜索后发现好几年前就已经有人在提这个问题了,不过直到日前在android开发者网站上仍然把这个问题列为希望在将来解决的问题。不过这个问题后来通过一个比较绕的方法解决了,就是在Log库module的build.gradle中加入defaultPublishConfig配置,变成这样:
1
2
3
4
5
6
7
8
9
defaultConfig {
minSdkVersion 15
targetSdkVersion 23
versionCode 1
versionName "1.0"

//如果要使用buildTypes中debug版的log配置,则设置为debug;否则,设置为release
defaultPublishConfig "debug"
}

这样就可以指定是使用debug版还是release版的配置了。不过这样的库提供给用户也够麻烦的,因为用户不仅要设置自己app的buildType,还得每次手动来设置Log库module的编译type,搞不好一不小心两者设的不统一就麻烦了。

  • 坑②:
    如果以这样的方式来配置Log库,那么用户就只能以module源码的方式将Log库引入自己的工程。如果要把Log库打成aar包提供给用户怎么办?参数就没法自定义了吧。

所以,醒悟后就把对Log库的自定义配置参数从Log库的build.gradle移动到了用户app的build.gradle中,也就是在readme.md文件中说明使用方法,其中一步就是要用户与把示例代码拷贝到自己app的build.gradle中,在对Log进行初始化时进行配置参数的调用。当然,这里Log初始化的代码也是在说明文件中写好的,用户拷贝即可。

日志写入到文件

要实现把日志写入到sd的指定目录下,日志文件按照日期进行自动命名,每个日志文件要限制大小,达到文件大小上限后自动创建新的文件,等等。于是又google了良久,发现可以用java.util.logging.FileHandler配合java.util.logging.logger来实现。
要使用FileHandler,先要实现一个自己的Formatter类:

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
class MyFormatter extends Formatter {
String appPackageName;

public MyFormatter(String appPackageName) {
this.appPackageName = appPackageName;
}

@Override
public String format(LogRecord rec) {
StringBuffer buf = new StringBuffer();

SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

buf.append(timeFormat.format(new Date()));
buf.append('/');
buf.append(appPackageName);
buf.append(' ');
buf.append(MYLogLevel.getSimpleLevelStringByJavaLevel(rec.getLevel()));
buf.append('/');
buf.append(rec.getParameters()[0]);
buf.append(':');
buf.append(' ');
buf.append(formatMessage(rec));
buf.append('\n');
return buf.toString();
}
}

然后,创建一个FileHandler实例,并把MyFormatter的一个实例作为参数传给我们的FileHandler实例,并用FileHandler来作为我们的Logger的处理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
try {                                                                                                                                        
Logger logger = Logger.getLogger(packageName);
logger.setUseParentHandlers(false);

SimpleDateFormat dateformat1 = new SimpleDateFormat("yyyy-MM-dd");
String datestring = dateformat1.format(new Date());
fh = new FileHandler(file.getAbsolutePath() + "_" + datestring + "_%g" + ".log",
singleLogFileSizeLimit * 1024 * 1024, 9999, true);
fh.setFormatter(new MyFormatter(packageName));
logger.addHandler(fh);

return logger;
} catch (IOException e) {
e.printStackTrace();
if (fh != null) {
fh.close();
}

return null;
}

关于FileHandler实例化的参数,官网说明如下:

public class FileHandler
extends StreamHandler
Simple file logging Handler.
The FileHandler can either write to a specified file, or it can write to a rotating set of files.
For a rotating set of files, as each file reaches a given size limit, it is closed, rotated out, and a new file opened. Successively older files are named by adding “0”, “1”, “2”, etc. into the base filename.
By default buffering is enabled in the IO libraries but each log record is flushed out when it is complete.
By default the XMLFormatter class is used for formatting.
Configuration: By default each FileHandler is initialized using the following LogManager configuration properties. If properties are not defined (or have invalid values) then the specified default values are used.
java.util.logging.FileHandler.level specifies the default level for the Handler (defaults to Level.ALL).
java.util.logging.FileHandler.filter specifies the name of a Filter class to use (defaults to no Filter).
java.util.logging.FileHandler.formatter specifies the name of a Formatter class to use (defaults to java.util.logging.XMLFormatter)
java.util.logging.FileHandler.encoding the name of the character set encoding to use (defaults to the default platform encoding).
java.util.logging.FileHandler.limit specifies an approximate maximum amount to write (in bytes) to any one file. If this is zero, then there is no limit. (Defaults to no limit).
java.util.logging.FileHandler.count specifies how many output files to cycle through (defaults to 1).
java.util.logging.FileHandler.pattern specifies a pattern for generating the output file name. See below for details. (Defaults to “%h/java%u.log”).
java.util.logging.FileHandler.append specifies whether the FileHandler should append onto any existing files (defaults to false).
A pattern consists of a string that includes the following special components that will be replaced at runtime:
“/“ the local pathname separator
“%t” the system temporary directory
“%h” the value of the “user.home” system property
“%g” the generation number to distinguish rotated logs
“%u” a unique number to resolve conflicts
“%%” translates to a single percent sign “%”
If no “%g” field has been specified and the file count is greater than one, then the generation number will be added to the end of the generated filename, after a dot.
Thus for example a pattern of “%t/java%g.log” with a count of 2 would typically cause log files to be written on Solaris to /var/tmp/java0.log and /var/tmp/java1.log whereas on Windows 95 they would be typically written to C:\TEMP\java0.log and C:\TEMP\java1.log
Generation numbers follow the sequence 0, 1, 2, etc.
Normally the “%u” unique field is set to 0. However, if the FileHandler tries to open the filename and finds the file is currently in use by another process it will increment the unique number field and try again. This will be repeated until FileHandler finds a file name that is not currently in use. If there is a conflict and no “%u” field has been specified, it will be added at the end of the filename after a dot. (This will be after any automatically added generation number.)
Thus if three processes were all trying to log to fred%u.%g.txt then they might end up using fred0.0.txt, fred1.0.txt, fred2.0.txt as the first file in their rotating sequences.
Note that the use of unique ids to avoid conflicts is only guaranteed to work reliably when using a local disk file system.

配置日志库服务

我们希望日志库能够根据用户需求,把log打印到控制台、或者输出到SD卡的文件,或者在某些条件下把SD的日志文件上传到服务器,那么就需要以service的形式来提供日志库的功能。

大体的架构设计,就是把日志库service跑在一个独立的进程中,提供一个wrapper类供用户调用,对于日志库servcie的任何调用都对用户是透明的,用户只需要像使用android自带的Log工具那样使用.d()、.e()方法就可以了。每个app在自己的Application类中调用日志库wrappe类的初始化函数,在这一步传入app自己的相关信息如包名等,日志库会根据包名生成对应的日志文件。service中开一个新线程,里面显示地启动一个looper,外界发来的打印log的请求都被以消息的形式传递给这个新线程的消息队列来处理。

Thread的run方法大致如下:

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
@Override
public void run() {
Looper.prepare();
mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case Log.VERBOSE:
printToFile(msg, "VERBOSE");
break;

case Log.DEBUG:
printToFile(msg, "DEBUG");
break;

case Log.INFO:
printToFile(msg, "INFO");
break;

case Log.WARN:
printToFile(msg, "WARN");
break;

case Log.ERROR:
printToFile(msg, "ERROR");
break;

case Log.ASSERT:
printToFile(msg, "ASSERT");
break;
}
}
};
Looper.loop();
}

一开始servcie的工作线程是在orhanobut/logger之上做扩展,对于wrapper类的实现是使用AIDL,在app调用wrapper的初始化函数时,wrapper自动bind到日志库service,然后再通过AIDL调用service的初始化以及打印log的方法(这些对用户app都是透明的)。这里就遇到一个大坑:

如果以显示Intent的方法来启动servcie,就会发现把日志库以module源码的形式提供给app使用是没有问题的,但是如果打成aar包提供给app使用,就会出现bind失败,很奇怪。

如果以隐式Intent的方法来启动service,打成aar包也可以正常启动servie,但是由于android 5.0以上要求必须以显示Intent启动service,故这种方法的覆盖范围太小。

后来大神review了我的代码,说最好不要用AIDL,因为那样会一直在app和servcie之间保持一个长连接,没有必要。可以直接用start方法启动servcie,用intent来传递请求参数,也是一样的。再加上我写的第一版是以log4j的形式,虽然提供了gradle的参数配置日志库的各项功能,但是功能是写死的,无法扩展,如果用户有一些个性化的日志需求就没法用我这个东西。

所以后来就对代码进行了重构,改为在JakeWharton/timber之上扩展,我提供一个日志框架,以及两个日志工具(打印到控制台、打印到文件),用户也可以继承我的日志工具基类实现自己的日志工具。用户在自己的app中,根据业务需求,在初始化时选择一个或多个日志工具安装到框架,具体的打印请求也是同android默认日志工具一样直接.d()、.e()这样调用,很方便。

这里记一下踩到的另外几个坑,有些比较低级😓:
1、一开始之所以选择在orhanobut/logger上扩展,是因为它默认提供了非常美观的打印格式,以及线程信息、日志调用栈信息。后来突然反应过来,我提供的库虽然会与用户app打进同一个apk,wrapper也是与用户app跑在同一线程,但是真正的日志打印工作是在service的单独进程中完成的,那么它默认提供的调用栈信息就无意义了。

2、AIDL是同步调用,而service的所有其它接口(bind, unbind, startService, stopService)都是异步接口,立即返回。参考:http://blog.csdn.net/edisonlg/article/details/7164761)。

3、在调试时发现初始化貌似很耗时,跟踪了一下,发现new FileHandler()这一操作居然要耗时30~40ms!!!

文章目录
  1. 1. 调研
  2. 2. gradle直接配置日志库
  3. 3. 日志写入到文件
  4. 4. 配置日志库服务