在Android应用上线后,或多或少地都会出现各种问题,尤其是应用崩溃最让人崩溃,如果前期没有做好异常的捕获、崩溃日志的保存和上传的功能,那就很难定位到Bug的位置,久而久之,程序猿的头发又更少了...
要实现崩溃日志工具类,主要是要考虑两个方面的功能:
- 在出现崩溃时保存错误信息到日志文件
- 在某一时段上传错误日志(考虑到用户体验,所以放在下次打开应用时自动上传)
然后考虑到保存和上传的时机,大概的流程图应该就是这样:
1. 保存日志文件
所谓的崩溃都是由于Exception
(常见)和Error
(不常见)引起的。众所周知:Exception
和Error
的父类都是Throwable
,所以只要在报错的位置捕获到Throwable
,然后输出日志到文件即可。
- 这里有个问题,如何在不知道报错的位置情况下捕获到日志呢?这里就要用到
Thread.UncaughtExceptionHandler
接口和Thread.setDefaultUncaughtExceptionHandler()
方法了。
Thread.UncaughtExceptionHandler的官网解释是:当线程由于未捕获的异常突然终止时调用的处理器的接口。
当线程由于未捕获的异常而即将终止时,Java虚拟机将使用
Thread.getUncaughtExceptionHandler()
在线程中查询其UncaughtExceptionHandler
并将调用处理程序的uncaughtException()
方法,将线程和异常作为该方法的参数传递。如果未显式设置线程的UncaughtExceptionHandler
,则其ThreadGroup
对象将充当其UncaughtExceptionHandler
。如果ThreadGroup
对象对处理异常没有特殊要求,则可以将调用转发到默认的未捕获异常处理器。
ThreadGroup:顾名思义就是线程所在的线程组,详细可以点击查看。
Thread.setDefaultUncaughtExceptionHandler()官网解释是:设置默认的异常处理器的全局静态方法,传入的必须是Thread.UncaughtExceptionHandler
的实现类。
未捕获的异常处理首先由线程控制,然后由线程的
ThreadGroup
对象控制,最后由默认的未捕获的异常处理器控制。如果线程没有设置显式的未捕获异常处理器,并且线程的线程组(包括父线程组)未专门设置其uncaughtException()
方法,则将调用默认处理器的uncaughtException()
方法。
通过设置默认的未捕获异常处理器,应用程序可以更改那些已经接受系统提供的“默认”行为的线程的未捕获异常处理方式(例如,记录到特定设备或文件)。
请注意,默认的未捕获异常处理器通常不应遵从线程的ThreadGroup
对象,因为这可能导致无限递归。
这样,全局捕获异常的问题算是解决了,接下来新建工具类,实现Thread.UncaughtExceptionHandler
接口,这里通过lazy延迟属性,使用双重校验锁实现单例。
class CrashHandler : Thread.UncaughtExceptionHandler {
companion object {
//双重校验锁实现单例
val instance: CrashHandler by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
CrashHandler()
}
}
fun init(context: Context) {
// 设置CrashHandler为应用的默认异常处理器
Thread.setDefaultUncaughtExceptionHandler(this)
}
override fun uncaughtException(thread: Thread?, exception: Throwable?) {
//在此中解析exception
}
}
这里如何获取Throwable
中的信息呢?答案是用StringWriter
和PrintWriter
,在Throwable
实例的printStackTrace()
方法中获取到堆栈信息。
private fun getExceptionInfo(exception: Throwable?): String {
val sw = StringWriter()
val pw = PrintWriter(sw)
exception?.printStackTrace(pw)
return sw.toString()
}
报错日志拿到了,但是不能够去影响到系统处理异常,该报错还是得报错,所以在设置默认异常处理器前要通过Thread.getDefaultUncaughtExceptionHandler()
方法获取原来的系统默认处理器,并在保存文件之后,将异常信息原封不动地传给原来的系统默认处理器。
private var mDefaultCrashHandler: Thread.UncaughtExceptionHandler? = null
fun init(context: Context) {
//注意要在设置前获取
mDefaultCrashHandler = Thread.getDefaultUncaughtExceptionHandler()
// 设置CrashHandler为应用的默认异常处理器
Thread.setDefaultUncaughtExceptionHandler(this)
}
override fun uncaughtException(thread: Thread?, exception: Throwable?) {
//在此中解析exception,保存日志文件,可开启子线程写入文件或者使用kotlin的协程
// 系统默认处理
mDefaultCrashHandler?.uncaughtException(thread, exception)
}
至此,基本的崩溃日志保存就完成了。什么?怎么保存到文件?直接新建文件夹,写入获取到的堆栈信息到文件,再详细的话欢迎百度。
2. 上传日志文件
上传日志文件这个其实不用多说,用Okhttp
或者Retrofit
就完事了。
这里主要是考虑上传文件的时机,如果在应用崩溃时保存文件并上传,而且可能等待日志是否上传成功,在这种情况下会导致应用无法操作卡顿后一段时间才崩溃,这样肯定是不行的,所以上传日志文件放在初始化时上传比较好。
3. 日志信息的完善和可自定义
要完善崩溃日志工具类,可能就以下几点:
- 增加手机基本信息
- 可控制的日志文件数量
- 文件存储的位置
获取手机信息然后加入日志文件中,能了解到更多相关信息。日志文件数量可以调节,为0时不保存错误日志。自定义错误日志保存的目录,方便自测时查看。然后,大概就是下面这样子:
/**
* 崩溃日志处理类
* @author JPlus
* @date 2019/3/14.
*/
class CrashHandler : Thread.UncaughtExceptionHandler {
companion object {
val instance: CrashHandler by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
CrashHandler()
}
}
private var mDefaultCrashHandler: Thread.UncaughtExceptionHandler? = null
private var mContext: Context? = null
private var mDirPath: String? = null
private var mMaxNum = 0
/**
* 初始化
* @param context 上下文
* @param maxNum 最大保存文件数量,默认为1
* @param dir 存储文件的目录,默认为应用私有文件夹下crash目录
*/
fun init(context: Context, maxNum: Int = 1, dir: String = FileUtils.writePrivateDir("crash", context).absolutePath) {
mContext = context
mDirPath = dir
mMaxNum = maxNum
mDefaultCrashHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler(this)
}
/**
* 获取最新崩溃日志
* @return 最新文件
*/
fun getNewFile(): File? {
//筛选出最近最新的一次崩溃日志
return FileUtils.getDirFiles(File(mDirPath))?.let {
if (it.size>0) it.reversed()[0] else null
}
}
private fun writeNewFile(path: String, name: String, body: String) {
FileUtils.getDirFiles(File(mDirPath))?.let {
if (it.size >= mMaxNum) {
//大于设置的数量则删除最旧文件
FileUtils.delFileOrDir(it.sorted()[0])
}
//继续存崩溃日志,新线程写入文件
GlobalScope.launch{
FileUtils.writeFile(File(path, name), body, false)
}
}
}
/**
* 当系统中有未被捕获的异常,系统将会自动调用 uncaughtException 方法
* @param thread
* @param exception
*/
override fun uncaughtException(thread: Thread?, exception: Throwable?) {
val name = AppUtils.instance.getDeviceImei(mContext!!) + "_" + DateUtils.getDateTimeByMillis(false).replace(":", "-")
val exceptionInfo = StringBuilder(name + "\n\n" + getSysInfo() + "\n\n" + exception?.message)
exceptionInfo.append("\n" + getExceptionInfo(exception))
mDirPath?.let {
if (mMaxNum > 0) {
writeNewFile(it, "$name.log", exceptionInfo.toString())
}
}
// 系统默认处理
mDefaultCrashHandler?.uncaughtException(thread, exception)
}
private fun getSysInfo(): String {
val map = hashMapOf<String, String>()
map["versionName"] = AppUtils.instance.getAppVersionName(mContext)
map["versionCode"] = "" + AppUtils.instance.getAppVersionCode(mContext)
map["androidApi"] = "" + AppUtils.instance.getOsLevel()
map["product"] = "" + AppUtils.instance.getDeviceProduct()
map["mobileInfo"] = AppUtils.instance.getDeviceInfo()
map["cpuABI"] = AppUtils.instance.getCpuABI()
val str = StringBuilder("=".repeat(10) + "PhoneInfo" + "=".repeat(10) + "\n")
for (item in map) {
str.append(item.key).append(" = ").append(item.value).append("\n")
}
str.append("=".repeat(10) + "=".repeat(10) + "\n")
return str.toString()
}
private fun getExceptionInfo(exception: Throwable?): String {
val sw = StringWriter()
val pw = PrintWriter(sw)
exception?.printStackTrace(pw)
return sw.toString()
}
}
至此,一个简单的崩溃日志工具类实现了,可能或多或少有待改进的地方,欢迎批评指正。
完整项目地址:baselibrary/CrashHandler