[Android] 013. Logger

ログ表示あれこれ
いい感じのLoggerが無いので結局自作になりそう

Logcatに表示

String

まずは基本のStringのおさらい

// String型は""
var text = "text"

var num = 0
val test = "test"

// 変数を挿入するときは$をつける
text = "$test text $num"
// NG 変数testtextと解釈されて存在してないからエラー
//text = "$testtext $num"
// OK 英数字以外で区切る
text = "$test:text$num"
// OK または${}で囲う
text = "${test}text$num"

// 式を代入するときは${}で囲う
text = "${++num} ${num + 1}"

// 結合は+フォーマット指定はString.format()
text += String.format(" %s %04d", test, num)

// 数値の桁あわせは以下でも
//text = "${num.toString().padStart(4, '0')}"
text = num.toString().padStart(4, '0')

// 16進数表示(大文字)
text = Integer.toHexString(num.toInt()).uppercase()

// Java同様Kotlinもkotlin.text.StringBuilderが使える
val text_ = StringBuilder(text)
text_.append(test)
text_.append(num)
text = text_.toString()

println

logcatに表示する標準機能

// I/System.out: (ログの内容)
println("...")

android.util.Log

// V/(TAG): (ログの内容)
Log.v("TAG","...")
// D/(TAG): (ログの内容)
Log.d("TAG","...")
// I/(TAG): (ログの内容)
Log.i("TAG","...")
// W/(TAG): (ログの内容)
Log.w("TAG","...")
// E/(TAG): (ログの内容)Timber
Log.e("TAG","...")
// E/(TAG): (ログの内容)
Log.wtf("TAG","...")

出力したい内容 / Exceptionハンドリング

Thread ID / Line番号 / Class Name / Methord Name

StackTraceElementで情報が取れるが、Thread.currentThread().getStackTrace() / Throwable().stackTraceで内容が変わる
どのindexのElementを使うかは目的に合わせて実装が必要

val threadId = Integer.toHexString(Thread.currentThread().id.toInt()).uppercase().padStart(4, '0')
val threadName = Thread.currentThread().name ?: ""
// Thread.currentThread().getStackTrace().forEach {
Throwable().stackTrace.forEach {
    val text = String.format("%s[%s] L%04d %s %s", threadName, threadId , it.lineNumber, it.className, it.methodName)
    println(text)
}

DefaultUncaughtExceptionHandler

/**
 * アプリ全体でthrowしたExceptionをハンドリングします
 *
 * ApplicationクラスのonCreateなどで呼び出してください
 *
 * 例.
 * ```kotlin
 * setUncaughtExceptionHandler(isExit = true) { thread, throwable ->
 *     println(thread.toString())
 *     throwable.printStackTrace()
 * }
 * ```
 *
 * @param isExit 意図しないException発生時はアプリが不安定になることが多いためアプリ終了をサポートしています
 */
fun setUncaughtExceptionHandler(isExit: Boolean = true, process: (thread: Thread, throwable: Throwable) -> Unit) {
    val handler = Thread.UncaughtExceptionHandler { thread, throwable ->
        process(thread, throwable)

        //TODO: ApplicationやActivityのonCreate()完了前にハンドリングしてしまった場合無限ループになる
        if (isExit) android.os.Process.killProcess(android.os.Process.myPid())
    }
    Thread.setDefaultUncaughtExceptionHandler(handler)
}

Logger(自作)

アプリのパフォーマンスにも影響あるのでファイル出力は要件が無い限りデバック時のみの利用が良いかも
ファイルサイズやファイルのインクリメントとかは配慮してないです
クラッシュやANRはGoogle Play ConsoleとかFirebase Crashlyticsとかを利用がよさそう

/**
 * getCallMethodElement()を実行したメソッドを呼び出したメソッドのStackTraceElementを返します
 *
 * ```kotlin
 * val element = getCallMethodElement()
 * element?.let {
 *     println(String.format("line:%04d class:%s method:%s", it.lineNumber, it.className, it.methodName))
 * }
 * ```
 */
fun getCallMethodElement(): StackTraceElement? {
    val element: StackTraceElement = Throwable().stackTrace[1]
    var find = false
    Thread.currentThread().stackTrace.forEach {
        if (find) {
            val target: String = it.methodName.split("$")[0]
            if (target != element.methodName) return it
        }
        if (it.className.equals(element.className) && it.methodName.equals(element.methodName)) find = true
    }

    return null
}

class Logger {
    enum class LogLevel(val value: Int) {
        VERBOSE(Log.VERBOSE),
        DEBUG(Log.DEBUG),
        INFO(Log.VERBOSE),
        WARN(Log.WARN),
        ERROR(Log.ERROR),
        ASSERT(Log.ASSERT);
    }

    companion object {
        private var TAG: String = ""
        private var fileName: String? = null
        @Volatile private var queue: ArrayDeque<String>? = null
        private var scope: CoroutineScope? = null

        fun initialize(tag: String = "", fileName: String? = null) {
            TAG = tag
            this.fileName = fileName
            if (fileName != null) {
                queue = ArrayDeque()
                scope = MainScope()
            }
        }

        private fun output(context: Context) {
            queue?.let {
                while (it.isNotEmpty()) {
                    val text: String = it.first()
                    it.removeFirst()

                    context.openFileOutput(fileName, Context.MODE_PRIVATE or Context.MODE_APPEND).use { stream ->
                        stream.write(text.toByteArray())
                    }
                }
            }
        }

        private fun write(context: Context, text: String) {
            queue?.let {
                val now = LocalDateTime.now()
                val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")
                val line = String.format("%s %s\n", now.format(formatter), text)

                it.add(line)
                scope?.launch {
                    output(context)
                }
            }
        }

        private fun log(level: Int, tag: String, message: String, element: StackTraceElement?, context: Context? = null) {
            var text = message
            element?.let {
                text = String.format("[%04x] L%04d %s#%s: %s", Thread.currentThread().id, it.lineNumber, it.className, it.methodName, message)
            }

            when (level) {
                LogLevel.VERBOSE.value -> Log.v(tag, text)
                LogLevel.DEBUG.value -> Log.d(tag, text)
                LogLevel.INFO.value -> Log.i(tag, text)
                LogLevel.WARN.value -> Log.w(tag, text)
                LogLevel.ERROR.value -> Log.e(tag, text)
                LogLevel.ASSERT.value -> Log.wtf(tag, text)
                else -> Log.v(tag, text)
            }

            context?.let {
                val lv = when (level) {
                    LogLevel.VERBOSE.value -> "V"
                    LogLevel.DEBUG.value -> "E"
                    LogLevel.INFO.value -> "I"
                    LogLevel.WARN.value -> "W"
                    LogLevel.ERROR.value -> "E"
                    LogLevel.ASSERT.value -> "C"
                    else -> "V"
                }

                write(it, String.format("%s %s", lv, text))
            }
        }
        private fun log(level: LogLevel, tag: String, message: String, element: StackTraceElement?, context: Context? = null) {
            log(level.value, tag, message, element, context)
        }

        fun v(message: String, context: Context? = null) {
            log(LogLevel.VERBOSE, TAG, message, getCallMethodElement(), context)
        }
        fun d(message: String, context: Context? = null) {
            log(LogLevel.DEBUG, TAG, message, getCallMethodElement(), context)
        }
        fun i(message: String, context: Context? = null) {
            log(LogLevel.INFO, TAG, message, getCallMethodElement(), context)
        }
        fun w(message: String, context: Context? = null) {
            log(LogLevel.WARN, TAG, message, getCallMethodElement(), context)
        }
        fun e(message: String, context: Context? = null) {
            log(LogLevel.ERROR, TAG, message, getCallMethodElement(), context)
        }
        fun wtf(message: String, context: Context? = null) {
            log(LogLevel.ASSERT, TAG, message, getCallMethodElement(), context)
        }
    }
}

サードベンダーのLogger

Timber

TimberはLogcatの拡張でLogcatの出力はandroid.util.Logと同じ
TAGを省略できたりフォーマットを変えるのに便利だけどサードベンダーに依存したくないから試すだけ

Project Structureで以下をimplementation
com.jakewharton.timber

class TimberDebugTree(private val context: Context, private val tag: String): Timber.DebugTree() {
    override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
        super.log(priority, String.format("%s: %s", this.tag, tag), message, t)
    }

    override fun createStackElementTag(element: StackTraceElement): String {
        return String.format("%s@%s[%04x] L%d", element.className, element.methodName, Thread.currentThread().id, element.lineNumber)
    }
}

fun initializeTimber(context: Context, tag: String, isDebug: Boolean) {
    if (isDebug) {
        Timber.plant(TimberDebugTree(context, tag))
    }
}

class MainActivity : ComponentActivity() {
    ...
    initializeTimber(this, "TAG", BuildConfig.DEBUG)
    Timber.v("...")
    Timber.d("...")
    Timber.i("...")
    Timber.w("...")
    Timber.e("...")
    Timber.wtf("...")
}

log4j2 Kotlin(使えない)

https://logging.apache.org/log4j/kotlin/index.html
https://github.com/apache/logging-log4j-kotlin

以下のエラーが出て使えない…設定が悪いのだろうか?
LogManager.getContext()がSimpleLoggerContextになってて、原因はlog4j-coreのclasspathが通ってないかららしいが…
ERROR StatusLogger Unable to load services for service class org.apache.logging.log4j.spi.Provider

結構粘ったけど無理して使うほどのものでも無いので不採用

android {
    packagingOptions {
        resources {
            excludes += '/META-INF/DEPENDENCIES'
        }
    }
}

dependencies {
    implementation 'org.apache.logging.log4j:log4j-api-kotlin:1.2.0'
    implementation 'org.apache.logging.log4j:log4j-api:2.19.0'
    implementation 'org.apache.logging.log4j:log4j-core:2.19.0'
}
import org.apache.logging.log4j.kotlin.Logging

class MainActivity : ComponentActivity(), Logging {
   ...
   logger.debug("...")
   logger.info("...")
   logger.warn("...")
   logger.error("...")
   logger.fatal("...")
}

log4j2.xmlをAndroid StudioのProjectのどこに置いても読み込まれないらしいのでAssetsに置く例

val xml = assets.open("log4j2.xml").reader(charset=Charsets.UTF_8).use{ it.readText() }
val stream: InputStream = ByteArrayInputStream(xml.toByteArray())

val source: ConfigurationSource? = ConfigurationSource(stream)
source?.let {
    val context = LogManager.getContext(false)
    val factory = org.apache.logging.log4j.core.config.xml.XmlConfigurationFactory.getInstance()
    // Castできずエラー
    val config = factory.getConfiguration(context as LoggerContext?, it)
    Configurator.initialize(config)
    config.start()
}
val logger = LogManager.getLogger("test")
logger.debug("...")

Android Studio Dolphin 2021.3.1 Patch 1 built on September 30, 2022