简介
Log是常用的调试手段,项目上线后发现有莫名其妙的问题,这时除了单纯的Crash收集外,还想到能够收集到用户本地的Log信息。这就要求增加可输出Log日志到本地的模块。
棘手的问题主要有:
- 单条日志如何高效组织,并包含基本的Line、Method信息。如果单条日志过长,则很容易撑大本地log文件,造成上传不便或者引起各类安全软件的注意;如果单条日志过简,又无法收集足够信息。
- 文件读写操作和管理。比如控制log日志只保留最近两天,单个文件大小不超过2MB。
核心类有两个:
- LocalLog,用于在代码中调用各级别(debug、info、error等)log。
- LogWriteHelper,用于存储日志到本地。
详解
LocalLog.java
提供各级别静态方法。这个好理解,无非和平时Log.d一致。
/**
* Reference : boolean : %b. byte, short, int, long, Integer,
* Long : %d. NOTE %x for hex. String : %s. Object : %s, for this
* occasion, toString of the object will be called, and the
* object can be null - no exception for this occasion.
*
* @param obj
* @param format
* @param args
*/
public static void d(Object obj, String format, Object... args) {
try {
String msg = null;
if (format != null) {
msg = String.format(format, args);
} else {
msg = Arrays.toString(args);
}
if (TextUtils.isEmpty(msg)) {
return;
}
int line = getCallerLineNumber();
String filename = getCallerFilename();
String logText = msgForTextLog(MsgType_Debug, obj, filename, line, msg);
if (isDebug) {
Log.d(TAG, logText);
}
if (isLogUpload && externalStorageExist()) {
writeToLog(logText);
}
} catch (java.util.IllegalFormatException e) {
e.printStackTrace();
}
}
可以看出,isDebug、isLogUpload和externalStorageExist都是日志是否输出的先决条件。这些boolean值在Application中初始化,externalStorageExist可自行判断,不再赘述。
感兴趣的地方有三:
1. getCallerLineNumber&getCallerFilename。
顾名思义,这些信息其实可以在当前线程的堆栈信息中取到:
1 | Thread.currentThread().getStackTrace() |
上述方法返回StackTrace数组。有意思的是,调用打印log这句代码的信息是固定在第5个。如图:
于是便变得简单。
private static int getCallerLineNumber() {
return Thread.currentThread().getStackTrace()[4].getLineNumber();
}
private static String getCallerFilename() {
return Thread.currentThread().getStackTrace()[4].getFileName();
}
private static String getCallerMethodName() {
return Thread.currentThread().getStackTrace()[4].getMethodName();
}
2. msgForTextLog。
拼装log信息,这里一并添加了进程信息、线程信息、类信息等。
private static String msgForTextLog(String MsgType, Object obj, String filename, int line,
String msg) {
StringBuilder sb = new StringBuilder();
sb.append(MsgType);
sb.append(msg);
sb.append("(P:");
sb.append(android.os.Process.myPid());
sb.append(")");
sb.append("(T:");
sb.append(Thread.currentThread().getId());
sb.append(")");
sb.append("(C:");
if (objClassName(obj) == "String") {
sb.append(obj);
} else {
sb.append(objClassName(obj));
}
sb.append(")");
sb.append("at (");
sb.append(filename);
sb.append(":");
sb.append(line);
sb.append(")");
String ret = sb.toString();
return ret;
}
3. writeToLog。
调用LogWriteHelpr写数据。Log文件以当前日期为单位,即每天只生产一个本地日志。这点用SimpleDateFormat格式化后很容易做到。
LogWriteHelper.java
提供写log到file的方法。除此外,为了完成管理本地log,控制日志保存天数、单个日志大小等因素,还需要增加以下功能:
- 控制日志保存天数。根据日期,在程序每次启动时判断最近N天内应保留的文件名,扫描log目录,删除过期文件。
- 控制日志大小。根据文件大小,每当writeToFile后(在最后才能保证该条log信息不丢失)判断文件大小是否超过定义值。如果超过则新启动线程将原本的Log文件剪切为之前的一半,因为我们所需log往往是最新的。同时置Cutting标志位为true,有新log需要写入文件时,提供新的目的地xxx.collect来保存这段时间的log,子线程完成Cut后将.collect文件内容写回并删除。期间要注意多线程同步问题。
核心代码如下:
public class LogWriteHelper {
public static final String TAG = "LogWriteHelper";
public static final int MAX_DAYS = 2;// Max days
public static final int MAX_FILE_SIZE = 10 * 1024;//Max Mb
public static final String LOG_SUFFIX_NAME = "logs.txt";
private static final SimpleDateFormat LOG_FORMAT = new SimpleDateFormat(
"yyyy-MM-dd kk:mm:ss.SSS", Locale.getDefault());
private static boolean sOnCutting = false;//use a working flag, so you can collect log during CUT.
private static final String CUT_TEMP_SUFFIX_NAME = ".temp";
private static final String COLLECT_TEMP_SUFFIX_NAME = ".collect";
public synchronized static void writeLogToFile(String fileName, String msg)
throws IOException {
//prepare dir and file
File fileToWrite = null;
File esdf = Environment.getExternalStorageDirectory();
String dir = esdf.getAbsolutePath() + LocalLog.LOG_DIR_NAME;
File dirFile = new File(dir);
if (!dirFile.exists()) {
dirFile.mkdirs();
}
final File logFile = new File(dir + File.separator + fileName);
final String logFilePath = logFile.getAbsolutePath();
if (!logFile.exists()) {
logFile.createNewFile();
}
File tempFile = new File(logFile.getAbsolutePath() + COLLECT_TEMP_SUFFIX_NAME);
final String tempFilePath = tempFile.getAbsolutePath();
if (sOnCutting) {
//if cut is running, use temp file replace
if (!tempFile.exists()) {
tempFile.createNewFile();
}
fileToWrite = tempFile;
} else {
fileToWrite = logFile;
}
//combine DATE and LOG
Date date = new Date();
String strLog = LOG_FORMAT.format(date);
StringBuffer sb = new StringBuffer(strLog);
sb.append(' ');
sb.append(msg);
sb.append('\n');
strLog = sb.toString();
//write into file
FileWriter fileWriter = new FileWriter(fileToWrite, true);
fileWriter.write(strLog);
fileWriter.flush();
fileWriter.close();
if (!sOnCutting && logFile.length() > MAX_FILE_SIZE) {
//check every time.
sOnCutting = true;
if (!tempFile.exists()) {
tempFile.createNewFile();
}
new Thread(new CutFileRunnable(logFile, logFilePath, tempFilePath)).start();
}
}
static class CutFileRunnable implements Runnable {
public CutFileRunnable(File logFile, String logFilePath, String tempFilePath) {
this.logFile = logFile;
this.logFilePath = logFilePath;
this.tempFilePath = tempFilePath;
}
private File logFile;
private String logFilePath;
private String tempFilePath;
@Override
public void run() {
//step1 cut
boolean b = cutLogToHalf(logFile);
Log.d(TAG, "child thread run cutHalf result=" + b);
if (b) {
//step2 copy other log into half file.
boolean b1 = mergeCollectToLog(logFilePath, tempFilePath);
Log.d(TAG, "mergeCollectToLog result=" + b1 + ", thread=" + Thread.currentThread().getName());
}
}
}
/**
* @param logFilePath
* @param tempFilePath
* @return true if success
*/
private synchronized static boolean mergeCollectToLog(String logFilePath, String tempFilePath) {
long start = System.currentTimeMillis();
File logFile = new File(logFilePath);
File tempLogFile = new File(tempFilePath);
if (!logFile.exists() || !tempLogFile.exists()) {
Log.e(TAG, "mergeCollectToLog, file not exists! thread=" + Thread.currentThread().getName());
} else {
BufferedReader bufferedReader = null;
BufferedWriter bufferedWriter = null;
try {
FileWriter fileWriter = new FileWriter(logFile, true);
bufferedWriter = new BufferedWriter(fileWriter);
FileReader fileReader = new FileReader(tempLogFile);
bufferedReader = new BufferedReader(fileReader);
String thisLine = null;
while ((thisLine = bufferedReader.readLine()) != null) {
bufferedWriter.write(thisLine);
bufferedWriter.newLine();
}
tempLogFile.delete();
Log.d(TAG, "temp collect log file delete. thread=" + Thread.currentThread().getName());
long end = System.currentTimeMillis();
Log.d(TAG, "copy tempLog spend: " + (end - start) / 1000 + "seconds.");
return true;
} catch (IOException e) {
e.printStackTrace();
} finally {
sOnCutting = false;
closeQuietly(bufferedReader);
closeQuietly(bufferedWriter);
}
}
sOnCutting = false;
return false;
}
/**
* use RandomAccessFile. copy old data into temp file, then remove old file and rename the temp.
*
* @param old
* @return true if success
*/
private static boolean cutLogToHalf(File old) {
Log.i(TAG, "logFile is too big! need cut half.");
long start = System.currentTimeMillis();
BufferedWriter bfdTempWriter = null;
RandomAccessFile randomAccessFile = null;
try {
//prepare temp file
if (!old.exists()) {
return false;
}
File temp = new File(old.getAbsolutePath() + CUT_TEMP_SUFFIX_NAME);
if (temp.exists()) {
temp.delete();
}
temp.createNewFile();
FileWriter tempWriter = new FileWriter(temp);
bfdTempWriter = new BufferedWriter(tempWriter);//fixme better buf?
//read from old file, start at half
randomAccessFile = new RandomAccessFile(old, "r");
long newStart = randomAccessFile.length() / 2;
randomAccessFile.seek(newStart);
randomAccessFile.readLine();//just skip this line.
bfdTempWriter.write("-----CUT HALF START " + LOG_FORMAT.format(new Date()) + "-----\n");
String thisLine = null;
while ((thisLine = randomAccessFile.readLine()) != null) {
bfdTempWriter.write(thisLine);
bfdTempWriter.newLine();
}
//rename. remove.
if (!old.delete() || !temp.renameTo(old.getAbsoluteFile())) {
Log.e(TAG, "cutHalfFile, delete or rename error!");
}
long end = System.currentTimeMillis();
Log.d(TAG, "cutHalf file to: " + old.length() / 1024 + "kb, spend: " + (end - start) / 1000 + "seconds.");
//2m --> 1m: about 8s.
return true;
} catch (IOException e) {
Log.e(TAG, "cutHalfFile error!", e);
} finally {
closeQuietly(randomAccessFile);
closeQuietly(bfdTempWriter);
}
return false;
}
/**
* delete expire log files depends on MAX_DAYS.
*/
public static void deleteExpireLogFiles() {
if (!LocalLog.externalStorageExist()) {
return;
}
File esdf = Environment.getExternalStorageDirectory();
String dir = esdf.getAbsolutePath() + LocalLog.LOG_DIR_NAME;
File dirFile = new File(dir);
if (!dirFile.exists()) {
dirFile.mkdirs();
}
Calendar c = Calendar.getInstance();
ArrayList<String> remainFileNames = new ArrayList<>();
remainFileNames.add(new File(dir + File.separator + LocalLog.getLogFileName(c.getTime())).getName());
for (int i = 1; i < MAX_DAYS; i++) {
c.add(Calendar.DATE, -1);
remainFileNames.add(new File(dir + File.separator + LocalLog.getLogFileName(c.getTime())).getName());
}
Log.d(TAG, "LogFileNames need to remain: " + remainFileNames.toString());
for (File f : dirFile.listFiles()) {
if (!remainFileNames.contains(f.getName()) && f.exists() && f.isFile()) {
f.delete();
Log.d(TAG, "delete logFile: " + f.getAbsolutePath());
}
}
}
public static void createTestLogFiles(int days) {
if (!LocalLog.externalStorageExist()) {
return;
}
File esdf = Environment.getExternalStorageDirectory();
String dir = esdf.getAbsolutePath() + LocalLog.LOG_DIR_NAME;
File dirFile = new File(dir);
if (!dirFile.exists()) {
dirFile.mkdirs();
}
Calendar c = Calendar.getInstance();
int temp = 0;
for (int i = 1; i < days; i++) {
c.add(Calendar.DATE, -1);
File logFile = new File(dir + File.separator + LocalLog.getLogFileName(c.getTime()));
if (!logFile.exists()) {
try {
if (logFile.createNewFile()) {
temp++;
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
Log.d(TAG, "createTestLogFile: number=" + temp);
}
public static void closeQuietly(Closeable closeable) {
try {
closeable.close();
} catch (IOException e) {
//ignore
}
}
}
总结
实测可用,如果程序没有Crash监测功能(例如Umeng),还可以通过UncaughtExceptionHandler来完成Crash信息的收集。
除此之外,这里没有写到如何上传LogFile到自己的服务器。因为每人使用的Http框架有所不同,Api也多种多样。总体上说就是利用multipart/form-data,使用Post请求携带file上传。记得上传前做Zip压缩工作,这样会使原本2mb的文件小到几十k,十分可观。