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 Logger calls enqueueLog(level, tag, message, throwable).
  • The LogWriter runs in a background thread, continuously reading from a thread-safe queue (like LinkedBlockingQueue).
  • It writes logs to app.log asynchronously.

✅ 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 Logger calls enqueueLog(level, tag, message, throwable).
  • The LogWriter runs in a background thread, continuously reading from a thread-safe queue (like LinkedBlockingQueue).
  • It writes logs to app.log asynchronously.

Current implementation version:

  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

    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

  1. Thread-safe queue: LinkedBlockingQueue is perfect for this use case
  2. Daemon thread: Prevents the thread from keeping the app alive
  3. Atomic state management: AtomicBoolean for thread-safe initialization
  4. Lazy initialization check: Prevents enqueueing before initialization
  5. 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

  1. Log rotation / size management
    Right now app.log will grow indefinitely. Add a max size (e.g., 5 MB) and rotate (rename app.logapp.log.1, etc.). Close the writer before renaming and reopen it afterwards.
  2. Buffered writer reuse
    Creating a new FileWriter for every message is expensive. Keep a single BufferedWriter (or FileChannel) open for the lifetime of the dispatcher, guarded by a lock.
  3. Bounded queue handling
    LinkedBlockingQueue() without capacity can grow unbounded if the consumer gets stuck. Pick a reasonable MAX_QUEUE_SIZE and 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.
  4. Graceful shutdown
    Calling stop() sets isRunning to false, but the worker thread is still blocked on queue.take(). Keep a reference to the worker, interrupt it, and optionally push a “poison pill” so it can exit cleanly and flush the writer.
  5. Initialization lifecycle
    Make init idempotent and safe to call multiple times (e.g., in tests). Likewise, if someone calls stop() and then init() again, it should start a fresh worker. Right now isRunning would remain false and nothing restarts.
  6. Optional Logcat mirroring
    You’re always mirroring to Logcat (Log.*). If you want full control, allow toggling that (e.g., debug builds only).
  7. 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 提供对话内容

标签: none

评论已关闭