Android 写本地日志工具类,项目中应该会比较常用,在此记录一下
java
package com.eternal.base.utils;
import android.content.Context;
import android.os.Environment;
import android.util.Log;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 本地日志写入工具类
* <p>
* 功能:
* 1. 支持多级别日志(Info / Warn / Error / Debug)
* 2. 异步写入,不阻塞主线程
* 3. 自动按日期分文件
* 4. 线程安全
* 5. 支持异常堆栈记录
* 6. 自动清理过期日志
* </p>
* <p>
* 使用示例:
* <pre>
* // 初始化(Application.onCreate 中调用一次)
* WriteFileLog.init(context);
*
* // 写日志
* WriteFileLog.i("TAG", "普通信息");
* WriteFileLog.e("TAG", "错误信息", throwable);
* </pre>
* </p>
*/
public class WriteFileLog {
private static final String TAG = "WriteFileLog";
/** 日志级别常量 */
private static final String LEVEL_INFO = "I";
private static final String LEVEL_WARN = "W";
private static final String LEVEL_ERROR = "E";
private static final String LEVEL_DEBUG = "D";
/** 日志文件后缀 */
private static final String LOG_SUFFIX = ".log";
/** 日志文件夹名称(位于应用外部存储 files 目录下) */
private static final String LOG_DIR_NAME = "logs";
/** 单个日志文件保留天数,超过自动删除 */
private static final int RETAIN_DAYS = 7;
/** 单个日志文件最大大小(字节),超过则自动滚动生成新文件 */
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024L; // 10MB
private static volatile boolean sInitialized = false;
private static File sLogDir;
/** 单线程写入器,保证线程安全且按顺序落盘 */
private static final ExecutorService sExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "WriteFileLog-Writer");
t.setDaemon(true);
t.setPriority(Thread.MIN_PRIORITY);
return t;
});
private static final SimpleDateFormat FILE_DATE_FORMAT =
new SimpleDateFormat("yyyy-MM-dd", Locale.US);
private static final SimpleDateFormat LOG_DATE_FORMAT =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US);
private WriteFileLog() {
throw new UnsupportedOperationException("Utility class");
}
/**
* 初始化日志工具类,建议在 {@code Application.onCreate()} 中调用一次。
*
* @param context 应用上下文
*/
public static void init(Context context) {
if (context == null) {
Log.e(TAG, "init: context is null");
return;
}
Context app = context.getApplicationContext();
// 使用应用专属外部存储目录,无需申请 WRITE_EXTERNAL_STORAGE 权限(Android 10+)
File externalFilesDir = app.getExternalFilesDir(null);
if (externalFilesDir == null) {
// 回退到内部 files 目录
externalFilesDir = app.getFilesDir();
}
sLogDir = new File(externalFilesDir, LOG_DIR_NAME);
if (!sLogDir.exists() && !sLogDir.mkdirs()) {
Log.e(TAG, "init: failed to create log dir: " + sLogDir.getAbsolutePath());
}
sInitialized = true;
// 启动时清理过期日志
cleanExpiredLogs();
Log.i(TAG, "WriteFileLog initialized, log dir: " + sLogDir.getAbsolutePath());
}
/** 返回日志文件所在目录,未初始化时返回 null */
public static File getLogDir() {
return sLogDir;
}
/** ---------------------- 对外 API ---------------------- */
public static void i(String tag, String msg) {
write(LEVEL_INFO, tag, msg, null);
}
public static void w(String tag, String msg) {
write(LEVEL_WARN, tag, msg, null);
}
public static void w(String tag, String msg, Throwable tr) {
write(LEVEL_WARN, tag, msg, tr);
}
public static void e(String tag, String msg) {
write(LEVEL_ERROR, tag, msg, null);
}
public static void e(String tag, String msg, Throwable tr) {
write(LEVEL_ERROR, tag, msg, tr);
}
public static void d(String tag, String msg) {
write(LEVEL_DEBUG, tag, msg, null);
}
/**
* 通用写入方法。
*
* @param level 日志级别
* @param tag 标签
* @param msg 消息内容
* @param tr 可选的异常堆栈
*/
public static void write(final String level, final String tag,
final String msg, final Throwable tr) {
if (!sInitialized) {
// 未初始化时仅打印到 Logcat,避免丢失关键信息
Log.w(TAG, "WriteFileLog not initialized, fallback to Logcat");
return;
}
final long timestamp = System.currentTimeMillis();
final String threadName = Thread.currentThread().getName();
// 在主调线程同步获取调用位置,异步后拿不到真实调用栈
final String caller = getCallerInfo();
sExecutor.execute(() -> doWrite(level, tag, msg, tr, timestamp, threadName, caller));
}
/** ---------------------- 内部实现 ---------------------- */
private static void doWrite(String level, String tag, String msg, Throwable tr,
long timestamp, String threadName, String caller) {
File targetFile = resolveLogFile(timestamp);
if (targetFile == null) return;
String time = formatTime(LOG_DATE_FORMAT, new Date(timestamp));
StringBuilder sb = new StringBuilder(256);
sb.append(time).append(' ')
.append(level).append('/').append(tag).append(' ')
.append('[').append(threadName).append("] ")
.append(caller).append(" - ")
.append(msg != null ? msg : "null")
.append('\n');
if (tr != null) {
sb.append(getStackTraceString(tr)).append('\n');
}
BufferedWriter writer = null;
try {
// append = true,追加写入
writer = new BufferedWriter(new FileWriter(targetFile, true), 8192);
writer.write(sb.toString());
writer.flush();
} catch (IOException e) {
Log.e(TAG, "doWrite failed", e);
} finally {
if (writer != null) {
try { writer.close(); } catch (IOException ignored) { }
}
}
}
/**
* 根据时间戳解析当前要写入的日志文件。
* 超过 {@link #MAX_FILE_SIZE} 时自动滚动(文件名附加序号)。
*/
private static File resolveLogFile(long timestamp) {
String date = formatTime(FILE_DATE_FORMAT, new Date(timestamp));
// 先尝试基础文件名
File base = new File(sLogDir, date + LOG_SUFFIX);
if (!base.exists() || base.length() < MAX_FILE_SIZE) {
return base;
}
// 超过大小上限,滚动生成带序号的文件
int index = 1;
File rolled;
while (true) {
rolled = new File(sLogDir, date + "_" + index + LOG_SUFFIX);
if (!rolled.exists() || rolled.length() < MAX_FILE_SIZE) {
return rolled;
}
index++;
// 安全兜底,避免死循环
if (index > 1000) return base;
}
}
private static String getStackTraceString(Throwable tr) {
if (tr == null) return "";
StringWriter sw = new StringWriter(256);
PrintWriter pw = new PrintWriter(sw);
tr.printStackTrace(pw);
pw.flush();
return sw.toString();
}
/**
* 解析调用者的类名、方法名和行号。
* <p>
* 调用链示例:caller -> i() -> write() -> getCallerInfo() -> Throwable
* 栈帧索引:
* [0] java.lang.Thread.getStackTrace
* [1] WriteFileLog.getCallerInfo
* [2] WriteFileLog.write
* [3] WriteFileLog.i / w / e / d
* [4] 真正的调用者
* </p>
* 通过按类名过滤跳过本类的所有帧,第一个非本类帧即为真实调用者。
*/
private static String getCallerInfo() {
StackTraceElement[] stack = Thread.currentThread().getStackTrace();
String selfClass = WriteFileLog.class.getName();
boolean foundSelf = false;
for (StackTraceElement element : stack) {
String className = element.getClassName();
if (className.equals(selfClass)) {
foundSelf = true;
continue;
}
if (foundSelf) {
// 形如:MainActivity.init(MainActivity.java:58)
return element.getClassName()
+ "." + element.getMethodName()
+ "(" + element.getFileName()
+ ":" + element.getLineNumber() + ")";
}
}
return "Unknown";
}
private static String formatTime(SimpleDateFormat sdf, Date date) {
// SimpleDateFormat 非线程安全,每次加锁调用
synchronized (sdf) {
return sdf.format(date);
}
}
/** 删除超过 {@link #RETAIN_DAYS} 天的旧日志文件 */
public static void cleanExpiredLogs() {
if (sLogDir == null || !sLogDir.exists()) return;
sExecutor.execute(() -> {
long cutoff = System.currentTimeMillis() - RETAIN_DAYS * 24L * 60 * 60 * 1000;
File[] files = sLogDir.listFiles();
if (files == null) return;
int deleted = 0;
for (File f : files) {
if (f.isFile() && f.lastModified() < cutoff) {
if (f.delete()) deleted++;
}
}
if (deleted > 0) {
Log.i(TAG, "cleanExpiredLogs: deleted " + deleted + " expired log files");
}
});
}
/** 清空全部日志文件(谨慎使用) */
public static void clearAllLogs() {
if (sLogDir == null || !sLogDir.exists()) return;
sExecutor.execute(() -> {
File[] files = sLogDir.listFiles();
if (files == null) return;
for (File f : files) {
if (f.isFile()) f.delete();
}
Log.i(TAG, "clearAllLogs: done");
});
}
/**
* 判断 SD 卡是否可用。
* 用于外部写入前预检。
*/
public static boolean isExternalStorageWritable() {
String state = Environment.getExternalStorageState();
return Environment.MEDIA_MOUNTED.equals(state);
}
}
使用前在 Application 里初始化一下
java
@Override
public void onCreate() {
super.onCreate();
try {
// 关闭位置权限会重新启动App,而不启动MainActivity,通过反射重新初始化MainModel
this.mViewModel = createViewModel("com.eternal.main.model.MainModel");
} catch (Exception e) {
KLog.e(e);
}
if (BuildConfig.DEBUG) {
WriteFileLog.init(this);
}
}