Android 保存崩溃日志

在Android应用上线后,或多或少地都会出现各种问题,尤其是应用崩溃最让人崩溃,如果前期没有做好异常的捕获、崩溃日志的保存和上传的功能,那就很难定位到Bug的位置,久而久之,程序猿的头发又更少了...


要实现崩溃日志工具类,主要是要考虑两个方面的功能:

  • 在出现崩溃时保存错误信息到日志文件
  • 在某一时段上传错误日志(考虑到用户体验,所以放在下次打开应用时自动上传)

然后考虑到保存和上传的时机,大概的流程图应该就是这样:


崩溃日志流程图
1. 保存日志文件

所谓的崩溃都是由于Exception(常见)和Error(不常见)引起的。众所周知:ExceptionError的父类都是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中的信息呢?答案是用StringWriterPrintWriter,在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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342