可保存Log到本地的模块

简介

Log是常用的调试手段,项目上线后发现有莫名其妙的问题,这时除了单纯的Crash收集外,还想到能够收集到用户本地的Log信息。这就要求增加可输出Log日志到本地的模块。

棘手的问题主要有:

  1. 单条日志如何高效组织,并包含基本的Line、Method信息。如果单条日志过长,则很容易撑大本地log文件,造成上传不便或者引起各类安全软件的注意;如果单条日志过简,又无法收集足够信息。
  2. 文件读写操作和管理。比如控制log日志只保留最近两天,单个文件大小不超过2MB。

核心类有两个:

  1. LocalLog,用于在代码中调用各级别(debug、info、error等)log。
  2. 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,十分可观。