前言
目前大多数库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。具体步骤如下:
- 写入文件
20231128.log
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进阶资料
敲代码不易,关注一下吧。ღ( ´・ᴗ・` )