日志工具
日志工具是日常开发中必不可少的工具,日志工具的功能一方面是开发时的实时打印,用于进行调试;另一个方面就是输出日志文件,当程序运行出现异常的时候用于定位问题;之前在项目中负责了日志工具的开发工作,最近也有同事咨询日志性能相关的东西,感觉还是有些技巧可以总结一波的。
日志打印功能
基本打印
对于日志打印,Android提供了Log类用于日志打印,打印日志分不同的等级,便于日志工具的封装,一般使用带日志等级的方法进行打印,而不是直接调用相应等级的方法;其中需要注意的一点是当tag
参数TextUtils.isEmpty()
时,是不会进行打印输出的;
/**
* Low-level logging call.
* @param priority The priority/type of this log message 日志等级
* @param tag Used to identify the source of a log message. It usually identifies TAG
* the class or activity where the log call occurs.
* @param msg The message you would like logged. 打印的信息
* @return The number of bytes written.
*/
public static int println(int priority, String tag, String msg) {
return println(LOG_ID_MAIN, priority, tag, msg);
}
打印优化
控制台打印出日志后,再根据日志找出打印的位置,代码比较多的时候还是比较耗时的,可以进行模仿AndroidStudio的Exception的打印,点击打印的时候就可以跳转到代码所对应的地方;点击跳转的日志打印固定格式如下
"(" + targetStackTrace.getFileName() + ":"+ targetStackTrace.getLineNumber() + ")"
除此以外,还可以把当前线程和方法名打印出来
/**
* 获取日志出处
*
* @return
*/
private static String getTargetStackTraceElement() {
StackTraceElement targetStackTrace = null;
boolean shouldTrace = false;
//获取线程堆栈转储的堆栈跟踪元素数组
StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
//遍历元素数组
for (StackTraceElement stackTraceElement : stackTrace) {
//该对象是否是打印日志自身
boolean isLogMethod = stackTraceElement.getClassName().equals(LogUtils.class.getName());
//如果上一个对象是日志工具本身并且该对象不是则证明该对象就是使用日志工具的类
if (shouldTrace && !isLogMethod) {
//保存调用日志工具的对象
targetStackTrace = stackTraceElement;
break;
}
//保存上一个对象是不是打印工具本身
shouldTrace = isLogMethod;
}
//获取线程名
String tName = Thread.currentThread().getName();
//获取调用日志工具执行的方法
String methodName = targetStackTrace.getMethodName();
//进行拼接
return tName + " -> " + methodName + "(" + targetStackTrace.getFileName() + ":"
+ targetStackTrace.getLineNumber() + ")";
}
也可以在一次打印前后加上一些分割线,这样看起来会更直观、更易用;打印效果如下
具体实现是在进行日志打印的时候拿到线程堆栈转储的堆栈跟踪元素数组,这里面的信息是当前线程的堆栈信息;我们可以对比一下系统的异常输出日志,一般有异常抛出e.printStackTrace
会全部输出到控制台,如下图所示;可以看出来第一个输出的是异常抛出的方法,之后都是上级的方法;同理我们获取到线程的堆栈信息后只想打印一下调用日志工具的地方的信息,就需要从这个stackTrace
数组里面去找,取第一个的话肯定是日志工具类本身的方法,不是我们想要的,所以代码里面进行了判断,找第一个不是日志工具类的对象就好了;其实这里也可以根据代码直接写死取第几个对象,因为调用日志工具类的方法后执行的方法个数肯定是固定的;
除此以外还可以对特殊格式的字符串进行打印的优化,比如说提供对Json数据进行格式化打印的方法,这个看起来会很方便;具体的实现就是做一些简单的字符替换,网上一大堆这里就不再给出代码;
输入日志到文件
输出日志到文件还是比较简单的,只需要开启一个输出流,将打印的日志输出到日志文件中即可;
/**
* 写日志到文件,示例简化代码
* @param logMsg
*/
private void writeLogFile(String logMsg){
FileOutputStream fos = null;
String logFilePath ="sdcard/log.txt";
File file = new File(logFilePath);
fos = new FileOutputStream(file, true);
fos.write(logMsg.getBytes());
//关闭流
fos.close();
}
多线程调用
但是如果只是这样实现的话肯定是有问题的,写文件操作肯定是需要考虑性能的,每执行一次方法就会开启一个输出流,会对性能造成很大的影响,很容易出现内存溢出;因此只能开启一个输出流,可以将输出流设置为成员变量每次执行完以后不关闭,APP退出时再进行关闭;但是对于多线程调用,多个线程同时进行写文件操作也可能会出现问题,可以通过synchronized
加锁来实现同步;
/**
* 文件输出流
*/
private FileOutputStream mOutputStream;
/**
* 写日志到文件,示例简化代码
* @param msg
*/
private synchronized void writeLogFile(String msg){
if(mOutputStream==null){
mOutputStream=new FileOutputStream(new File("/sdcard/log.txt"));
}
mOutputStream.write(msg.getBytes());
mOutputStream.flush();
}
建立写入缓存区
当日志频繁打印的时候,每次打印一行,会不停的执行文件写入操作,效率比较低,会对性能造成一定的影响;可以使用带缓冲区的输出流BufferedOutputStream
设置一定的内存缓冲区大小,先把日志数据先写入缓冲区,等缓冲区满了,再把数据写到文件里,能够大量减少 IO 次数,提高效率;但是也是有缺点的,缓冲区设置的越大越能减少IO次数,但是当程序异常退出的时候缓冲区的日志就会丢失掉,设置越大,丢失的越多;下面会讲讲优化方法
/**
* 带缓冲区的输出流
*/
private BufferedOutputStream mOutputStream;
/**
* 写日志到文件,示例简化代码
*
* @param msg
*/
private synchronized void writeLogFile(String msg) {
if (mOutputStream == null) {
FileOutputStream fileOutputStream = new FileOutputStream(new File("/sdcard/log.txt"));
mOutputStream = new BufferedOutputStream(fileOutputStream,BUFF_SIZE);
}
byte[] bytes = msg.getBytes();
mOutputStream.write(bytes,0,bytes.length);
}
线程优化
写入日志到文件的操作是在子线程进行操作的,在执行加了锁后的方法writeLogFile
是内部私有的方法,我们需要对外提供一个方法,因为写文件是个耗时操作,所以这个方法是需要在子线程执行,这时候肯定就会使用线程池;使用线程池就会有线程的创建和回收,日志打印频繁也会对性能造成一定的影响;可以创建一个独立的线程进行写入日志到文件的操作,创建一个缓存区,对外部提供的方法将日志都放在缓冲区里面,线程里面循环从缓存区里面去读出日志,写入到文件中;这个缓存区可以使用一个数组或集合来实现,只要保证读写和删除的效率高即可;
/**
* 初始化日志工具,示例简化代码
*/
public void init() {
openWrite = true;
new Thread(new Runnable() {
@Override
public void run() {
FileOutputStream fileOutputStream = new FileOutputStream(new File("/sdcard/log.txt"));
BufferedOutputStream mOutputStream = new BufferedOutputStream(fileOutputStream, BUFF_SIZE);
while (openWrite) {
if (msgCache.size() > 0) {
byte[] bytes=msgCache.get(0).getBytes();
mOutputStream.write(bytes,0,bytes.length);
msgCache.remove(0);
}
}
}
}).start();
}
/**
* 将日志存入缓存区
*
* @param msg
*/
public void writeLogFile(String msg) {
if (msgCache.size() < LOG_MSG_CACHE_SIZE) {
msgCache.add(msg);
}
}
这里对于msgCache
这个集合也做了大小限制,因为极端情况,如果一直添加日志字符串到集合里面也会造成内存溢出,所以可以设置一下集合的大小控制一下,对于溢出的日志会被舍弃,也算是一个异常处理;讲道理一般情况是不可能出现的,如果出现了不做处理程序的性能肯定也会出问题。做一下计算如果每条日志是20个汉字,就是60个字节,如果缓存区设置为1M的话,也是可以缓存2万多条日志了;
日志缓存区优化
感觉上面的做法还是不是特别靠谱,虽然很少几率发生,但是毕竟丢弃日志还是不太友好的;这里可以通过阻塞队列ArrayBlockingQueue
代替普通的集合进行存储,初始化的时候需要指定队列的大小,当队列满了的时候会阻塞处理直到队列有空间,就不会主动进行日志的丢弃;
private ArrayBlockingQueue<String> mCacheLog = new ArrayBlockingQueue(2000);
日志文件压缩
日志多了以后日志文件的清理肯定是必要的,不然随着运行时间的延长日志文件会无限大;那么为了保存更多、更久的日志就可以对日志文件进行压缩,Android自带的压缩可以节省大约10以上倍的存储空间
//简写代码
//压缩后保存的文件的输出流
FileOutputStream fos = new FileOutputStream(zipFile);
ZipOutputStream zos = new ZipOutputStream(fos);
//原日志文件的输入流
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(file));
ZipEntry entry = new ZipEntry("" + file.getName());
zos.putNextEntry(entry);
int count;
byte[] buf = new byte[1024];
while ((count = bis.read(buf)) != -1) {
zos.write(buf, 0, count);
}
内存映射文件
上面只是一个非常普通的日志工具的实现,由于设置了缓冲区,当APP异常退出的时候就会导致日志的丢失;而且无论怎样优化还是避免不了文件的IO操作,这不是废话吗,功能就是写日志到文件,肯定避免不了;
其实还是有办法进行优化,就是mmap(一种内存映射文件的方法),即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系,函数原型如下
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。
mmap 的回写时机
- 内存不足
- 进程退出
- 调用 msync 或者 munmap
- 不设置 MAP_NOSYNC 情况下 30s-60s(仅限FreeBSD)
这篇文章介绍的非常不错 Android-内存映射mmap
简单点就是说,mmap操作提供了一种机制,可以让用户程序对内核空间的文件进行读写操作,这种机制不需要再将文件数据从内核空间读写到用户空间,相较于普通的文件读写,减少了一次数据的拷贝,效率更高;而且在内存不足或者进程退出的时候,会将数据写入文件,避免日志丢失的情况;
具体实现
可以在C++里面使用mmap
函数来实现,就是上面那个函数原型;不过在Java中也提供了内存映射的实现,就是MappedByteBuffer;先看看MappedByteBuffer的用法
RandomAccessFile raf = new RandomAccessFile(sFile, "rw");
raf = new RandomAccessFile(sFile, "rw");
//把文件从0开始到FILE_SIZE映射到内存中
MappedByteBuffer mByteBuffer = raf.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, FILE_SIZE);
byte[] msgbyte = msg.getBytes();
//写入内容
mByteBuffer.put(msgbyte);
可以把文件映射到内存中,获取到一个MappedByteBuffer对象,往这里面put
数据就会写入到文件中;那么第一个问题,这个FILE_SIZE
设置多大生成的日志文件就是多大;设置太大了,在日志没达到这个大小的时候应用退出,就浪费了,尤其是应用异常退出的时候,根本没机会对日志文件进行处理;设置小了,当日志文件达到这个大小的时候需要进行扩展,重新进行映射,这样的操作频繁了就会影响性能;
方案比较
测试数据 15byte的日志执行10万次,也就是写入1.43M数据到文件
方案 | 耗时/ms |
---|---|
普通文件输出流耗时 | 16756 |
缓冲区大小为默认大小(8192)的输出流耗时 | 977 |
设置内存映射文件,初始映射大小为1M时耗时 | 1151 |
冷静分析
就时间消耗来看,设置输出流缓冲区和内存映射性能差不多,比普通的输出流效率高了很多;讲道理,不应该是mmap
这种黑科技应该要厉害一点吗?我原本也是这样认为的,毕竟内存映射那一大堆不太能懂的描述看起来很厉害的样子。但是我们可以冷静分析一波,具体原理可以看上面那篇Android-内存映射mmap的文章;
这里简单点讲,普通读写文件(就是输入输出流的write
和read
等操作)是将硬盘的文件加载到内核空间,再复制到用户空间,用户才获取到数据,复制了两遍(这个顺序是从硬盘读文件,反之则是写文件);而mmap
内存映射就是只需要将文件从硬盘加载到内核空间,只复制一次用户就可以拿到数据;所以mmap
优势在于少复制一次;但是对于BufferedOutputStream
呢,虽然写一次文件需要复制两次,效率低,但是好在我有缓存区呀,我是大大减少了写入的次数;单次写入效率低的问题,就被减小了呀;而再反观mmap
虽然你写入的效率极高,但是你没有缓存区,写入的次数毕竟多呀;所以缓存区这个减少写入文件次数的对于效率的提升作用是非常大的,从测试耗时来看设置默认大小的缓存区对性能的提提升有17倍左右;
所以如果给mmap
加一个缓冲区,性能肯定会更高,但是加了缓冲区,应用异常退出,日志就可能丢失;鱼和熊掌不可得兼,其实感觉mmap
够了,效率够高又能防止日志丢失,好好写写MappedByteBuffer
的使用的逻辑还是很不错的。
总结
基本上这样的话日志工具应该就差不多能用了,具体实现逻辑的话还是要自己去实现,这里也只是谈到自己遇到的一些可以优化的点,感觉上还是比较简单的。