Android 将日志写入文件
package com.example.msdksample.utils
import android.util.Log
class Logger(private val tag: String) {
fun info(message: String) {
Log.i(tag, message)
}
fun error(message: String, throwable: Throwable? = null) {
if (throwable != null) {
Log.e(tag, message, throwable)
} else {
Log.e(tag, message)
}
}
fun debug(message: String) {
Log.d(tag, message)
}
fun warn(message: String) {
Log.w(tag, message)
}
}private val logger = Logger("XXXX")
logger.info("YYYYY")
logger.error("ZZZZZ")
I have this code fragment, Now, I want save log to a file app.log.
How to do this ?
In my mind, We should decouple the design. All logger play the role of log producers, and another role plays the role of log consumers, writing log information into a single log file.
💯 Exactly — that’s a great architectural insight.
Decoupling logging into producers (that generate log messages) and a single consumer (that writes to disk) is the proper way to design a scalable and thread-safe logging system.
This mirrors professional logging libraries (like Logback or Timber with appenders) — ensuring:
- No blocking on the main/UI thread.
- No race conditions.
- Centralized write control.
- (Optional) batching or async flushing for performance.
Let’s design this cleanly 👇
🧩 Architecture Overview
[Logger instances] ---> [Log Queue] ---> [LogWriter Thread/FileWriter]- Each
LoggercallsenqueueLog(level, tag, message, throwable). - The
LogWriterruns in a background thread, continuously reading from a thread-safe queue (likeLinkedBlockingQueue). - It writes logs to
app.logasynchronously.
✅ Implementation Example
1. LogMessage Data Class
data class LogMessage(
val timestamp: Long = System.currentTimeMillis(),
val level: String,
val tag: String,
val message: String,
val throwable: Throwable? = null
)2. LogDispatcher (Singleton Consumer)
package com.example.msdksample.utils
import android.content.Context
import android.util.Log
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean
object LogDispatcher {
private val queue = LinkedBlockingQueue<LogMessage>()
private lateinit var logFile: File
private val isRunning = AtomicBoolean(false)
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
fun init(context: Context) {
val logDir = File(context.filesDir, "logs")
if (!logDir.exists()) logDir.mkdirs()
logFile = File(logDir, "app.log")
if (isRunning.compareAndSet(false, true)) {
startWorker()
}
}
fun enqueue(log: LogMessage) {
if (!isRunning.get()) {
Log.e("LogDispatcher", "Dispatcher is not initialized. Call LogDispatcher.init(context) first.")
return
}
queue.offer(log)
}
private fun startWorker() {
Thread {
while (isRunning.get()) {
try {
val logMessage = queue.take() // blocks until item available
writeToFile(logMessage)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
isRunning.set(false)
} catch (e: Exception) {
Log.e("LogDispatcher", "Exception in log writer thread", e)
}
}
}.apply { name = "LogWriterThread"; isDaemon = true }.start()
}
private fun writeToFile(log: LogMessage) {
try {
FileWriter(logFile, true).use { writer ->
val timestamp = dateFormat.format(log.timestamp)
val line = buildString {
append("$timestamp [${log.level}/${log.tag}]: ${log.message}\n")
log.throwable?.let {
append(Log.getStackTraceString(it))
append("\n")
}
}
writer.append(line)
}
} catch (e: IOException) {
Log.e("LogDispatcher", "Failed to write log entry", e)
}
}
fun stop() {
isRunning.set(false)
}
fun getLogFilePath(): String = if (::logFile.isInitialized) logFile.absolutePath else ""
}3. Logger Class (Producers)
package com.example.msdksample.utils
import android.util.Log
class Logger(private val tag: String) {
fun info(message: String) {
Log.i(tag, message)
LogDispatcher.enqueue(LogMessage(level = "INFO", tag = tag, message = message))
}
fun debug(message: String) {
Log.d(tag, message)
LogDispatcher.enqueue(LogMessage(level = "DEBUG", tag = tag, message = message))
}
fun warn(message: String) {
Log.w(tag, message)
LogDispatcher.enqueue(LogMessage(level = "WARN", tag = tag, message = message))
}
fun error(message: String, throwable: Throwable? = null) {
if (throwable != null) Log.e(tag, message, throwable) else Log.e(tag, message)
LogDispatcher.enqueue(LogMessage(level = "ERROR", tag = tag, message = message, throwable = throwable))
}
}4. Application Setup
In your Application class (or main entry point):
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
LogDispatcher.init(this) // start background log writer
}
override fun onTerminate() {
super.onTerminate()
LogDispatcher.stop()
}
}Then, anywhere in your app:
val logger = Logger("MainActivity")
logger.info("App started")
logger.error("Something failed")⚡ Advantages of This Design
✅ Thread-safe — writes handled by a single dedicated worker thread.
✅ Non-blocking — log producers just enqueue quickly.
✅ Scalable — hundreds of loggers can coexist safely.
✅ Extensible — you can later add:
- Log file rotation
- Upload logs periodically
- Adjust log levels dynamically
- Write logs to multiple destinations (file, network, etc.)
To save logs to a file (like app.log) in addition to or instead of using Android’s Log system. And Make sure to manage file size.
Decoupling logging into producers (that generate log messages) and a single consumer (that writes to disk) is the proper way to design a scalable and thread-safe logging system.
Architecture Overview
[Logger instances] ---> [Log Queue] ---> [LogWriter Thread/FileWriter]- Each
LoggercallsenqueueLog(level, tag, message, throwable). - The
LogWriterruns in a background thread, continuously reading from a thread-safe queue (likeLinkedBlockingQueue). - It writes logs to
app.logasynchronously.
Current implementation version:
LogMessageData Classdata class LogMessage( val timestamp: Long = System.currentTimeMillis(), val level: String, val tag: String, val message: String, val throwable: Throwable? = null )LogDispatcher(Singleton Consumer)package com.example.msdksample.utils import android.content.Context import android.util.Log import java.io.File import java.io.FileWriter import java.io.IOException import java.text.SimpleDateFormat import java.util.Locale import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.atomic.AtomicBoolean object LogDispatcher { private val queue = LinkedBlockingQueue<LogMessage>() private lateinit var logFile: File private val isRunning = AtomicBoolean(false) private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) fun init(context: Context) { val logDir = File(context.filesDir, "logs") if (!logDir.exists()) logDir.mkdirs() logFile = File(logDir, "app.log") if (isRunning.compareAndSet(false, true)) { startWorker() } } fun enqueue(log: LogMessage) { if (!isRunning.get()) { Log.e("LogDispatcher", "Dispatcher is not initialized. Call LogDispatcher.init(context) first.") return } queue.offer(log) } private fun startWorker() { Thread { while (isRunning.get()) { try { val logMessage = queue.take() // blocks until item available writeToFile(logMessage) } catch (e: InterruptedException) { Thread.currentThread().interrupt() isRunning.set(false) } catch (e: Exception) { Log.e("LogDispatcher", "Exception in log writer thread", e) } } }.apply { name = "LogWriterThread"; isDaemon = true }.start() } private fun writeToFile(log: LogMessage) { try { FileWriter(logFile, true).use { writer -> val timestamp = dateFormat.format(log.timestamp) val line = buildString { append("$timestamp [${log.level}/${log.tag}]: ${log.message}\n") log.throwable?.let { append(Log.getStackTraceString(it)) append("\n") } } writer.append(line) } } catch (e: IOException) { Log.e("LogDispatcher", "Failed to write log entry", e) } } fun stop() { isRunning.set(false) } fun getLogFilePath(): String = if (::logFile.isInitialized) logFile.absolutePath else "" }LoggerClass (Producers)package com.example.msdksample.utils import android.util.Log class Logger(private val tag: String) { fun info(message: String) { Log.i(tag, message) LogDispatcher.enqueue(LogMessage(level = "INFO", tag = tag, message = message)) } fun debug(message: String) { Log.d(tag, message) LogDispatcher.enqueue(LogMessage(level = "DEBUG", tag = tag, message = message)) } fun warn(message: String) { Log.w(tag, message) LogDispatcher.enqueue(LogMessage(level = "WARN", tag = tag, message = message)) } fun error(message: String, throwable: Throwable? = null) { if (throwable != null) Log.e(tag, message, throwable) else Log.e(tag, message) LogDispatcher.enqueue(LogMessage(level = "ERROR", tag = tag, message = message, throwable = throwable)) } }Application Setup
class MyApp : Application() { override fun onCreate() { super.onCreate() LogDispatcher.init(this) // start background log writer } override fun onTerminate() { super.onTerminate() LogDispatcher.stop() } }Then, anywhere in my app:
val logger = Logger("MainActivity") logger.info("App started") logger.error("Something failed")
What is your opinion?
Your implementation is solid and well-architected! You've correctly implemented the producer-consumer pattern with proper thread safety. Here are my observations:
✅ What You Got Right
- Thread-safe queue:
LinkedBlockingQueueis perfect for this use case - Daemon thread: Prevents the thread from keeping the app alive
- Atomic state management:
AtomicBooleanfor thread-safe initialization - Lazy initialization check: Prevents enqueueing before initialization
- Clean separation: Producers (Logger) and consumer (LogDispatcher) are properly decoupled
🔧 Improvements Needed
1. File Size Management (Missing!)
You mentioned managing file size, but it's not implemented. Add log rotation:
private const val MAX_LOG_SIZE = 5 * 1024 * 1024 // 5MB
private const val MAX_BACKUP_FILES = 3
private fun writeToFile(log: LogMessage) {
try {
// Check file size and rotate if needed
if (logFile.exists() && logFile.length() > MAX_LOG_SIZE) {
rotateLogFiles()
}
FileWriter(logFile, true).use { writer ->
val timestamp = dateFormat.format(log.timestamp)
val line = buildString {
append("$timestamp [${log.level}/${log.tag}]: ${log.message}\n")
log.throwable?.let {
append(Log.getStackTraceString(it))
append("\n")
}
}
writer.append(line)
}
} catch (e: IOException) {
Log.e("LogDispatcher", "Failed to write log entry", e)
}
}
private fun rotateLogFiles() {
try {
// Delete oldest backup
File(logFile.parent, "app.log.${MAX_BACKUP_FILES}").delete()
// Shift existing backups
for (i in MAX_BACKUP_FILES - 1 downTo 1) {
val from = File(logFile.parent, "app.log.$i")
val to = File(logFile.parent, "app.log.${i + 1}")
if (from.exists()) from.renameTo(to)
}
// Rename current log to .1
logFile.renameTo(File(logFile.parent, "app.log.1"))
logFile.createNewFile()
} catch (e: Exception) {
Log.e("LogDispatcher", "Failed to rotate logs", e)
}
}2. Batch Writing for Performance
Opening/closing FileWriter for each log is inefficient. Consider batching:
private const val FLUSH_INTERVAL_MS = 1000L
private const val BATCH_SIZE = 50
private fun startWorker() {
Thread {
val batch = mutableListOf<LogMessage>()
var lastFlush = System.currentTimeMillis()
while (isRunning.get()) {
try {
val timeout = FLUSH_INTERVAL_MS - (System.currentTimeMillis() - lastFlush)
val logMessage = queue.poll(timeout.coerceAtLeast(0), TimeUnit.MILLISECONDS)
if (logMessage != null) {
batch.add(logMessage)
}
val shouldFlush = batch.size >= BATCH_SIZE ||
System.currentTimeMillis() - lastFlush >= FLUSH_INTERVAL_MS
if (shouldFlush && batch.isNotEmpty()) {
writeBatchToFile(batch)
batch.clear()
lastFlush = System.currentTimeMillis()
}
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
isRunning.set(false)
} catch (e: Exception) {
Log.e("LogDispatcher", "Exception in log writer thread", e)
}
}
// Flush remaining logs on shutdown
if (batch.isNotEmpty()) {
writeBatchToFile(batch)
}
}.apply { name = "LogWriterThread"; isDaemon = true }.start()
}
private fun writeBatchToFile(logs: List<LogMessage>) {
try {
FileWriter(logFile, true).use { writer ->
logs.forEach { log ->
val timestamp = dateFormat.format(log.timestamp)
writer.append("$timestamp [${log.level}/${log.tag}]: ${log.message}\n")
log.throwable?.let {
writer.append(Log.getStackTraceString(it))
writer.append("\n")
}
}
}
} catch (e: IOException) {
Log.e("LogDispatcher", "Failed to write log batch", e)
}
}3. Graceful Shutdown
Ensure all logs are flushed before stopping:
fun stop() {
isRunning.set(false)
// Add poison pill or flush remaining queue
Thread.sleep(500) // Give worker time to finish
}4. BufferedWriter Instead of FileWriter
For better I/O performance:
BufferedWriter(FileWriter(logFile, true)).use { writer ->
// ... write logs
}📊 Overall Assessment
Grade: A-
Your architecture is production-ready with minor additions. The main gap is the missing file rotation you mentioned. Once you add log rotation and consider batching, this becomes an excellent logging solution suitable for production apps.
Your overall architecture is solid: you’ve separated log producers from a single consumer, you’re using a blocking queue, and you’ve got a background thread doing the file I/O. That’s already much better than writing directly to disk from the UI or worker threads.
That said, there are a few gaps you’ll want to close before this is “production ready,” especially around file size management and resource handling.
Key improvements to consider
- Log rotation / size management
Right nowapp.logwill grow indefinitely. Add a max size (e.g., 5 MB) and rotate (renameapp.log→app.log.1, etc.). Close the writer before renaming and reopen it afterwards. - Buffered writer reuse
Creating a newFileWriterfor every message is expensive. Keep a singleBufferedWriter(orFileChannel) open for the lifetime of the dispatcher, guarded by a lock. - Bounded queue handling
LinkedBlockingQueue()without capacity can grow unbounded if the consumer gets stuck. Pick a reasonableMAX_QUEUE_SIZEand decide what to do when it’s full (drop the oldest, drop the new one, or block callers). A “drop oldest” strategy keeps recent logs. - Graceful shutdown
Callingstop()setsisRunningtofalse, but the worker thread is still blocked onqueue.take(). Keep a reference to the worker, interrupt it, and optionally push a “poison pill” so it can exit cleanly and flush the writer. - Initialization lifecycle
Makeinitidempotent and safe to call multiple times (e.g., in tests). Likewise, if someone callsstop()and theninit()again, it should start a fresh worker. Right nowisRunningwould remainfalseand nothing restarts. - Optional Logcat mirroring
You’re always mirroring to Logcat (Log.*). If you want full control, allow toggling that (e.g., debug builds only). - Queue backpressure visibility
If a log can’t be enqueued (queue full), consider counting drops and emitting a diagnostic once in a while (e.g., “Log queue overflowed, dropping messages”).
Updated LogDispatcher example (Kotlin)
package com.example.msdksample.utils
import android.content.Context
import android.util.Log
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.withLock
import java.util.concurrent.locks.ReentrantLock
object LogDispatcher {
private const val MAX_QUEUE_SIZE = 10_000
private const val MAX_FILE_SIZE_BYTES = 5L * 1024 * 1024 // 5 MB
private const val MAX_ROTATED_FILES = 3
private const val LOG_TAG = "LogDispatcher"
private val queue = LinkedBlockingQueue<LogMessage>(MAX_QUEUE_SIZE)
private val isRunning = AtomicBoolean(false)
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
private val writerLock = ReentrantLock()
private lateinit var logDir: File
private lateinit var logFile: File
private var bufferedWriter: BufferedWriter? = null
private var workerThread: Thread? = null
private var droppedLogsCount = 0
fun init(context: Context) {
if (isRunning.get()) return
logDir = File(context.filesDir, "logs").apply { if (!exists()) mkdirs() }
logFile = File(logDir, "app.log")
openWriter()
if (isRunning.compareAndSet(false, true)) {
startWorker()
}
}
fun enqueue(log: LogMessage) {
if (!isRunning.get()) {
Log.e(LOG_TAG, "Dispatcher not initialized. Call LogDispatcher.init(context) first.")
return
}
val accepted = queue.offer(log)
if (!accepted) {
// Drop the oldest entry to make room for the newest (optional strategy)
queue.poll()
if (!queue.offer(log)) {
droppedLogsCount++
if (droppedLogsCount % 100 == 1) { // log occasionally
Log.w(LOG_TAG, "Log queue full. Dropped $droppedLogsCount messages so far.")
}
}
}
}
fun stop() {
if (!isRunning.compareAndSet(true, false)) return
workerThread?.interrupt()
workerThread = null
writerLock.withLock {
bufferedWriter?.runCatching { flush() }
bufferedWriter?.runCatching { close() }
bufferedWriter = null
}
}
fun getLogFilePath(): String = if (::logFile.isInitialized) logFile.absolutePath else ""
private fun startWorker() {
workerThread = Thread {
try {
while (isRunning.get() || queue.isNotEmpty()) {
val logMessage = queue.poll() ?: run {
Thread.sleep(50) // avoid busy loop when shutting down
continue
}
writeToFile(logMessage)
}
} catch (ie: InterruptedException) {
Thread.currentThread().interrupt()
} catch (e: Exception) {
Log.e(LOG_TAG, "Exception in log writer thread", e)
} finally {
writerLock.withLock {
bufferedWriter?.runCatching { flush() }
bufferedWriter?.runCatching { close() }
bufferedWriter = null
}
}
}.apply {
name = "LogWriterThread"
isDaemon = true
start()
}
}
private fun writeToFile(log: LogMessage) {
val timestamp = dateFormat.format(log.timestamp)
val line = buildString {
append(timestamp)
append(" [${log.level}/${log.tag}]: ")
append(log.message)
if (!log.message.endsWith('\n')) append('\n')
log.throwable?.let {
append(Log.getStackTraceString(it))
append('\n')
}
}
writerLock.withLock {
try {
rotateIfNeeded(line.length)
bufferedWriter?.apply {
write(line)
flush()
}
} catch (ioe: IOException) {
Log.e(LOG_TAG, "Failed to write log entry", ioe)
}
}
}
private fun rotateIfNeeded(incomingLength: Int) {
if (logFile.length() + incomingLength <= MAX_FILE_SIZE_BYTES) return
bufferedWriter?.runCatching { close() }
bufferedWriter = null
for (index in MAX_ROTATED_FILES downTo 1) {
val source = File(logDir, "app.log.$index")
if (source.exists()) {
val target = File(logDir, "app.log.${index + 1}")
// Delete if target exists (beyond max)
if (index == MAX_ROTATED_FILES && target.exists()) target.delete()
source.renameTo(target)
}
}
val rotatedFile = File(logDir, "app.log.1")
if (rotatedFile.exists()) rotatedFile.delete()
logFile.renameTo(rotatedFile)
logFile = File(logDir, "app.log").apply { createNewFile() }
openWriter()
}
private fun openWriter() {
writerLock.withLock {
bufferedWriter = BufferedWriter(FileWriter(logFile, true))
}
}
}Logger tweaks (optional)
Keep your Logger class, but consider allowing the caller to disable Logcat mirroring:
class Logger(
private val tag: String,
private val mirrorToLogcat: Boolean = true
) {
fun info(message: String) {
if (mirrorToLogcat) Log.i(tag, message)
LogDispatcher.enqueue(LogMessage(level = "INFO", tag = tag, message = message))
}
fun debug(message: String) {
if (mirrorToLogcat) Log.d(tag, message)
LogDispatcher.enqueue(LogMessage(level = "DEBUG", tag = tag, message = message))
}
fun warn(message: String) {
if (mirrorToLogcat) Log.w(tag, message)
LogDispatcher.enqueue(LogMessage(level = "WARN", tag = tag, message = message))
}
fun error(message: String, throwable: Throwable? = null) {
if (mirrorToLogcat) {
if (throwable != null) Log.e(tag, message, throwable) else Log.e(tag, message)
}
LogDispatcher.enqueue(
LogMessage(level = "ERROR", tag = tag, message = message, throwable = throwable)
)
}
}Final thoughts
- The high-level design is sound; you just need to tighten up lifecycle and file management details.
- Make sure to stress-test with high log volumes and verify the rotation works as expected.
- Consider exposing a small API to dump the current log file (e.g., via share intent) for debugging purposes.
Once you incorporate file rotation and the other tweaks, you’ll have a scalable logging subsystem that respects disk space and plays nicely with Android’s threading constraints.
In startWorker() function, continue The IntelliJ IDEA says: The feature "break continue in inline lambdas" is experimental and should be enabled explicitly
IntelliJ is warning because of this bit:
val logMessage = queue.poll() ?: run {
Thread.sleep(50)
continue // <-- problematic
}The continue lives inside an inline lambda (run { ... }). Kotlin treats run as an inline higher-order function, and using continue/break inside those lambdas is still marked experimental unless you opt in via compiler flags. Rather than enabling experimental behavior, it’s simpler (and clearer) to rewrite the logic without nesting continue in a lambda.
Here’s a revised startWorker() that avoids the warning:
private fun startWorker() {
workerThread = Thread {
try {
while (isRunning.get() || queue.isNotEmpty()) {
val logMessage = queue.poll()
if (logMessage == null) {
Thread.sleep(50) // avoid busy loop when shutting down
continue
}
writeToFile(logMessage)
}
} catch (ie: InterruptedException) {
Thread.currentThread().interrupt()
} catch (e: Exception) {
Log.e(LOG_TAG, "Exception in log writer thread", e)
} finally {
writerLock.withLock {
bufferedWriter?.runCatching { flush() }
bufferedWriter?.runCatching { close() }
bufferedWriter = null
}
}
}.apply {
name = "LogWriterThread"
isDaemon = true
start()
}
}This keeps the behavior identical but eliminates the inline lambda, so IntelliJ won’t complain.
由 GPT-5-Chat, Claude-Sonnet-4.5, GPT-5-Codex 提供对话内容
评论已关闭