Android - 你可能需要这样一个日志库

前言

目前大多数库api设计都是Log.d("tag", "msg")这种风格,而且支持自定义日志存储的比较少, 所以作者想自己造一个轮子。

这种api风格有什么不好呢?

首先,它的tag是一个字符串,需要开发人员严格管理tag,要不然可能各种硬编码的tag满天飞。

另外,它也可能导致性能陷阱,假设有这么一段代码:

// 打印一个List
Log.d("tag", list.joinToString())

此处使用Debug打印日志,生产模式下调高日志等级,不打印这一行日志,但是list.joinToString()这一行代码仍然会被执行,有可能导致性能问题。

下文会分析作者期望的api是什么样的,本文演示代码都是用kotlin,库中好用的api也是基于kotlin特性来实现的。

作者写库有个习惯,对外开放的类或者全局方法都会加一个前缀f,一个是为了避免命名冲突,另一个是为了方便代码检索,以下文章中会出现,这里做一下解释。

期望

什么样的api才能解决上面的问题呢?我们看一下方法的签名和打印方式

inline fun <reified T : FLogger> flogD(block: () -> Any)

interface AppLogger : FLogger

flogD<AppLogger> {
    list.joinToString { it }
}

flogD方法打印Debug日志,传一个Flogger的子类AppLogger作为日志标识,同时传一个block来返回要打印的日志内容。

日志标识是一个类或者接口,所以管理方式比较简单不会造成tag混乱的问题,默认tag是日志标识类的短类名。生产模式下调高日志等级后,block就不会被执行了,避免了可能的性能问题。

实现分析

  • 支持限制日志大小,例如限制每天只能写入10MB的日志
  • 支持自定义日志格式
  • 支持自定义日志存储,即如何持久化日志

这一节主要分析一下实现过程中遇到的问题。

问题:如果App运行期间日志文件被意外删除了,怎么处理?

在Android中,用java.io的api对一个文件进行写入,如果文件被删除,继续写入的话不会抛异常,这样会导致日志丢失,该如何解决?

有同学说,在写入之前先检查文件是否存在,如果存在就继续写入,不存在就创建后写入。

检查一个文件是否存在通常是调用java.io.File.exist()方法,但是它比较耗性能,我们来做一个测试:

measureTime {
    repeat(1_0000) {
        file.exists()
    }
}.let {
    Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

14:50:33.536 MainActivity            com.sd.demo.xlog                I  time:39
14:50:35.872 MainActivity            com.sd.demo.xlog                I  time:54
14:50:38.200 MainActivity            com.sd.demo.xlog                I  time:43
14:50:40.028 MainActivity            com.sd.demo.xlog                I  time:53
14:50:41.693 MainActivity            com.sd.demo.xlog                I  time:58

可以看到1万次调用的耗时在50毫秒左右。

我们再测试一下对文件写入的耗时:

val output = filesDir.resolve("log.txt").outputStream().buffered()
val log = "1".repeat(50).toByteArray()
measureTime {
    repeat(1_0000) {
        output.write(log)
        output.flush()
    }
}.let {
    Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

14:57:56.092 MainActivity            com.sd.demo.xlog                I  time:38
14:57:56.558 MainActivity            com.sd.demo.xlog                I  time:57
14:57:57.129 MainActivity            com.sd.demo.xlog                I  time:57
14:57:57.559 MainActivity            com.sd.demo.xlog                I  time:46
14:57:58.054 MainActivity            com.sd.demo.xlog                I  time:54

可以看到1万次调用,每次写入50个字符的耗时也在50毫秒左右。如果每次写入日志前都判断一下文件是否存在,那么实际上相当于2次写入的性能成本,这显然很不划算。

还有同学说,开一个线程,定时判断文件是否存在,这样子虽然不会损耗单次写入的性能,但是又多占用了一个线程资源,显然也不符合作者的需求。

其实Android已经给我们提供了这种场景的解决方案,那就是android.os.MessageQueue.IdleHandler,关于IdleHandler这里就不展开讨论了,简单来说就是当你在主线程注册一个IdleHandler后,它会在主线程空闲的时候被执行。

我们可以在每次写入日志之后注册IdleHandler,等IdleHandler被执行的时候检查一下日志文件是否存在,如果不存在就关闭输出流,这样子在下一次写入的时候就会重新创建文件写入了。

这里要注意每次写入日志之后注册IdleHandler,并不是每次都创建新对象,要判断一下如果原先的对象还未执行的话就不用注册一个新的IdleHandler,库中大概的代码如下:

private class LogFileChecker(private val block: () -> Unit) {
    private var _idleHandler: IdleHandler? = null

    fun register(): Boolean {
        // 如果当前线程没有Looper则不注册,上层逻辑可以直接检查文件是否存在,因为是非主线程
        Looper.myLooper() ?: return false
        
        // 如果已经注册过了,直接返回
        _idleHandler?.let { return true }
        
        val idleHandler = IdleHandler {
            // 执行block检查任务
            libTryRun { block() }
            
            // 重置变量,等待下次注册
            _idleHandler = null
            false
        }
        
        // 保存并注册idleHandler
        _idleHandler = idleHandler
        Looper.myQueue().addIdleHandler(idleHandler)
        return true
    }
}

这样子文件被意外删除之后,就可以重新创建写入了,避免丢失大量的日志。

问题:如何检测文件大小是否溢出

库支持对每天的日志大小做限制,例如限制每天最多只能写入10MB,每次写入日志之后都会检查日志大小是否超过限制,通常我们会调用java.io.File.length()方法获取文件的大小,但是它也比较耗性能,我们来做一个测试:

val file = filesDir.resolve("log.txt").apply {
    this.writeText("hello")
}
measureTime {
    repeat(1_0000) {
        file.length()
    }
}.let {
    Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:56:04.090 MainActivity            com.sd.demo.xlog                I  time:61
16:56:05.329 MainActivity            com.sd.demo.xlog                I  time:80
16:56:06.382 MainActivity            com.sd.demo.xlog                I  time:72
16:56:07.496 MainActivity            com.sd.demo.xlog                I  time:79
16:56:08.591 MainActivity            com.sd.demo.xlog                I  time:78

可以看到耗时在60毫秒左右,相当于上面测试中1次文件写入的耗时。

库中支持自定义日志存储,在日志存储接口中定义了size()方法,上层通过此方法来判断当前日志的大小。

如果自定义了日志存储,避免在此方法中每次调用java.io.File.length()来返回日志大小,应该维护一个表示日志大小的变量,变量初始化的时候获取一下java.io.File.length(),后续通过写入的数量来增加这个变量的值,并在size()方法中返回。库中默认的日志存储实现类就是这样实现的。

问题:文件大小溢出后怎么处理?

假设限制每天最多只能写入10MB,那超过10MB后如何处理?有同学说直接删掉或者清空文件,重新写入,这也是一种策略,但是会丢失之前的所有日志。

例如白天写了9.9MB,到晚上的时候写满10MB,清空之后,白天的日志都没了,这时候用户反馈白天遇到的一个bug,需要上传日志,那就芭比Q了。

有没有办法少丢失一些呢?可以把日志分多个文件存储,为了便于理解假设分为2个文件存储,一天10MB,那1个文件最多只能写入5MB。具体步骤如下:

  1. 写入文件20231128.log
  2. 20231128.log写满5MB的时候关闭输出流,并把它重命名为20231128.log.1

这时候继续写日志的话,发现20231128.log文件不存在就会创建,又跳到了步骤1,就这样一直重复1和2两个步骤,到晚上写满10MB的时候,至少还有5MB的日志内容保存在20231128.log.1文件中避免丢失全部的日志。

分的文件数量越多,保留的日志就越多,实际上就是拿出一部分空间当作中转区,满了就向后递增数字重命名备份。目前库中只分为2个文件存储,暂时不开放自定义文件数量。

问题:打印日志的性能

性能,是这个库最关心的问题,通常来说文件写入操作是性能开销的大头,目前是用java.io相关的api来实现的,怎样提高写入性能作者也一直在探索,在demo中提供了一个基于内存映射的日志存储方案,但是稳定性未经测试,后续测试通过后可能会转正。

还有一个比较影响性能的就是日志的格式化,通常要把一个时间戳转为某个日期格式,大部分人都会用java.text.SimpleDateFormat来格式化,用它来格式化年:月:日的时候问题不大,但是如果要格式化时:分:秒.毫秒那它就比较耗性能,我们来做一个测试:

val format = SimpleDateFormat("HH:mm:ss.SSS")
val millis = System.currentTimeMillis()
measureTime {
    repeat(1_0000) {
        format.format(millis)
    }
}.let {
    Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:05:26.920 MainActivity            com.sd.demo.xlog                I  time:245
16:05:27.586 MainActivity            com.sd.demo.xlog                I  time:227
16:05:28.324 MainActivity            com.sd.demo.xlog                I  time:212
16:05:29.370 MainActivity            com.sd.demo.xlog                I  time:217
16:05:30.157 MainActivity            com.sd.demo.xlog                I  time:193

可以看到1万次格式化耗时大概在200毫秒左右。

我们再用java.util.Calendar测试一下:

val calendar = Calendar.getInstance()
// 时间戳1
val millis1 = System.currentTimeMillis()
// 时间戳2
val millis2 = millis1 + 1000
// 切换时间戳标志
var flag = true
measureTime {
    repeat(1_0000) {
        calendar.timeInMillis = if (flag) millis1 else millis2
        calendar.run {
            "${get(Calendar.HOUR_OF_DAY)}:${get(Calendar.MINUTE)}:${get(Calendar.SECOND)}.${get(Calendar.MILLISECOND)}"
        }
        flag = !flag
    }
}.let {
    Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:11:25.342 MainActivity            com.sd.demo.xlog                I  time:35
16:11:26.209 MainActivity            com.sd.demo.xlog                I  time:35
16:11:27.316 MainActivity            com.sd.demo.xlog                I  time:37
16:11:28.057 MainActivity            com.sd.demo.xlog                I  time:25
16:11:28.825 MainActivity            com.sd.demo.xlog                I  time:18


这里解释一下为什么要用两个时间戳,因为Calendar内部有缓存,如果用同一个时间戳测试的话,没办法评估它真正的性能,所以这里每次格式化之后就切换到另一个时间戳,避免缓存影响测试。

可以看到1万次的格式化耗时在30毫秒左右,差距很大。如果要自定义日志格式的话,建议用Calendar来格式化时间,有更好的方案欢迎和作者交流。

问题:日志的格式如何显示

手机的存储资源是宝贵的,如何定义日志格式也是一个比较重要的细节。

  • 优化时间显示

目前库内部是以天为单位来命名日志文件的,例如:20231128.log,所以在格式化时间戳的时候只保留了时:分:秒.毫秒,避免冗余显示当天的日期。

  • 优化日志等级显示

打印的时候提供了4个日志等级:Verbose, Debug, Info, Warning, Error,一般最常用的记录等级是Info,所以在格式化的时候如果等级是Info则不显示等级标志,规则如下:

private fun FLogLevel.displayName(): String {
    return when (this) {
        FLogLevel.Verbose -> "V"
        FLogLevel.Debug -> "D"
        FLogLevel.Warning -> "W"
        FLogLevel.Error -> "E"
        else -> ""
    }
}

  • 优化日志标识显示

如果连续2条或多条日志都是同一个日志标识,那么就只有第1条日志会显示日志tag

  • 优化线程ID显示

如果是主线程的话,不显示线程ID,只有非主线程才显示线程ID

经过上面的优化之后,日志打印的格式是这样的:

flogI<AppLogger> { "1" }
flogI<AppLogger> { "2" }
flogW<AppLogger> { "3" }
flogI<UserLogger> { "user debug" }
thread {
    flogI<UserLogger> { "thread" }
}

19:19:43.961[AppLogger] 1
19:19:43.974 2
19:19:43.975[W] 3
19:19:43.976[UserLogger] user debug
19:19:43.977[12578] thread

API

这一节介绍一下库的API,调用FLog.init()方法初始化,初始化如果不想打印日志,可以调用FLog.setLevel(FLogLevel.Off)关闭日志

常用方法
// 初始化
FLog.init(
    //(必传参数)日志文件目录
    directory = filesDir.resolve("app_log"),

    //(可选参数)自定义日志格式
    formatter = AppLogFormatter(),
    
    //(可选参数)自定义日志存储
    storeFactory = AppLogStoreFactory(),

    //(可选参数)是否异步发布日志,默认值false
    async = false,
)

// 设置日志等级 All, Verbose, Debug, Info, Warning, Error, Off  默认日志等级:All
FLog.setLevel(FLogLevel.All)

// 限制每天日志文件大小(单位MB),小于等于0表示不限制大小,默认限制每天日志大小100MB
FLog.setLimitMBPerDay(100)

// 设置是否打打印控制台日志,默认打开
FLog.setConsoleLogEnabled(true)

/**
 * 删除日志,参数saveDays表示要保留的日志天数,小于等于0表示删除全部日志,
 * 此处saveDays=1表示保留1天的日志,即保留当天的日志
 */
FLog.deleteLog(1)

打印日志
interface AppLogger : FLogger

flogV<AppLogger> { "Verbose" }
flogD<AppLogger> { "Debug" }
flogI<AppLogger> { "Info" }
flogW<AppLogger> { "Warning" }
flogE<AppLogger> { "Error" }

// 打印控制台日志,不会写入到文件中,不需要指定日志标识,tag:DebugLogger
fDebug { "console debug log" }

配置日志标识

可以通过FLog.config方法修改某个日志标识的配置信息,例如下面的代码:

FLog.config<AppLogger> {
    // 修改日志等级
    this.level = FLogLevel.Debug

    // 修改tag
    this.tag = "AppLoggerAppLogger"
}

自定义日志格式
class AppLogFormatter : FLogFormatter {
    override fun format(record: FLogRecord): String {
        // 自定义日志格式
        return record.msg
    }
}

interface FLogRecord {
    /** 日志标识 */
    val logger: Class<out FLogger>

    /** 日志tag */
    val tag: String

    /** 日志内容 */
    val msg: String

    /** 日志等级 */
    val level: FLogLevel

    /** 日志生成的时间戳 */
    val millis: Long

    /** 日志是否在主线程生成 */
    val isMainThread: Boolean

    /** 日志生成的线程ID */
    val threadID: String
}

自定义日志存储

日志存储是通过FLogStore接口实现的,每一个FLogStore对象负责管理一个日志文件。 所以需要提供一个FLogStore.Factory工厂为每个日志文件提供FLogStore对象。

class AppLogStoreFactory : FLogStore.Factory {
    override fun create(file: File): FLogStore {
        return AppLogStore(file)
    }
}

class AppLogStore(file: File) : FLogStore {
    // 添加日志
    override fun append(log: String) {}

    // 返回当前日志的大小
    override fun size(): Long = 0

    // 关闭
    override fun close() {}
}

如果你看到了这里,觉得文章写得不错就给个赞呗?
更多Android进阶指南 可以扫码 解锁更多Android进阶资料


在这里插入图片描述
敲代码不易,关注一下吧。ღ( ´・ᴗ・` )

  • 19
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值