拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Android崩溃日志捕获:手写CrashHandler实现本地异常记录

做Android开发这几年我最怕听到的一句话就是“我手机上一个按钮点了就闪退你那边有日志吗”问题是用户不会帮你抓logcat也不会adb pull遇到崩溃你能拿到的往往只有一句抱怨。要快速定位这种问题就得靠App自己把崩溃现场记录下来。今天要聊的项目就是Android中捕获APP崩溃异常信息存到手机通过全局未捕获异常处理器在应用崩掉之前把当前堆栈、设备型号、系统版本、App版本号、所在页面等信息写进本地文件。这套方案不引入第三方SDK手写一个CrashHandler就能落地特别适合中小项目、开源Demo和个人工具类App。不管你是刚接触Android的新手还是负责线上稳定性的老开发跟着走一遍都能快速搭出自己的崩溃日志收集体系。1. 项目背景与实际需求1.1 为什么线上崩溃必须靠日志定位很多崩溃之所以难处理不是代码本身多玄学而是缺少现场信息。开发阶段你能用Android Studio的Logcat直接看异常但用户手机上崩溃发生后你手里只剩一句“他那个手机不行”或者干脆没有任何反馈。线上崩溃要定位第一手资料就是日志异常类型、堆栈、设备型号、系统版本、App版本、操作页面。有了这些很多问题能直接确认比如“只有Android 13的某款机型崩”这时候基本可以怀疑某些系统API适配问题如果只有某个版本号崩就去看后端开关或者灰度策略如果堆栈停在某个第三方SDK那大概率是SDK初始化时机问题。所以崩溃日志不是“锦上添花”而是线上问题排查的生命线。虽然现在不少团队接了第三方崩溃统计平台但我一直主张项目里也要有一层自己的本地崩溃记录。原因很现实一是第三方SDK有体积和隐私成本二是内网或特殊场景可能不允许数据外发三是很多小工具类App压根不需要那么重的监控体系。本地留一份日志没有网络依赖用户反馈的时候你可以直接让他打开文件发给你自己在开发期也能通过adb拉取。自建这个模块并不是要完全替代专业级别的崩溃监控而是把最核心的“崩了之后留下记录”这件事做扎实。后面如果业务上量还可以在本地日志基础上扩展自动上传。1.2 自建捕获模块要解决什么先明确目标我们要捕获的是Java / Kotlin层未被捕获的RuntimeException和Error也就是那些没有被任何try/catch包住、导致线程终止的异常。捕获到之后要完成三件事第一把异常堆栈整理成可读文本第二把现场环境信息例如设备型号、系统版本、App版本、当前Activity等信息一起打包第三把以上内容写进手机的本地文件格式要清晰按时间生成独立文件不能崩一次就把上次的记录覆盖掉。技术选型上核心就是Thread类的setDefaultUncaughtExceptionHandler。这是Android / Java标准库自带的机制只要有JVM环境就可用不需要引入任何额外依赖。配合Application的onCreate完成注册再手写一个CrashHandler来统一处理“即将崩溃但还没完全退出”的窗口期。整个实现量很小几百行代码能搞定但里面的细节和坑并不少。提示一个经常被忽略的点是只能捕获Java层未处理异常。如果你是做NDK开发C/C层的native crash不会走这套逻辑那需要单独接入Breakpad之类的方案本文先不展开。2. 全局异常捕获的底层原理2.1 Thread.setDefaultUncaughtExceptionHandler 到底做了什么Java的线程管理里每个线程都有一个未捕获异常处理器。如果某个线程抛出了异常并且没有在当前方法栈中被捕获那么这个异常就会沿着调用栈向外抛。等到线程结束前虚拟机会尝试调用UncaughtExceptionHandler接口的uncaughtException(Thread t, Throwable e)方法。Android的主线程也一样。平时我们看到“App已停止运行”弹窗其实系统就是通过默认的异常处理器通知zygote进程处理这个错误。我们可以用自定义处理器替换掉默认实现这样在系统弹窗之前代码获得一次机会去处理崩溃残留信息。注册代码极其简单Thread.setDefaultUncaughtExceptionHandler(new CrashHandler());为什么是“Default”因为除了默认处理器Thread还允许给单个线程设置handler。你可以调用thread.setUncaughtExceptionHandler()单独给某个子线程指定。但为了全局兜底我们用默认的即可所有没有单独指定处理器的线程最终都会走到这个全局默认处理器。有一个细节值得注意在CrashHandler内部最好把系统原来的默认处理器保存下来。因为当你的uncaughtException执行完系统大概率还是要走默认的处理流程比如弹出崩溃框或者直接杀掉进程。如果你把默认处理器丢掉了可能导致崩溃后App既不退出也不弹窗进入一种“假死”状态。所以正确的做法是自己处理后再把异常转交给原始处理器让它接管后续的系统默认行为。2.2 崩溃现场能做的事和不能做的事当uncaughtException被调用时应用其实已经处于不稳定状态了。尤其是主线程崩溃整个进程摇摇欲坠很多服务可能已经不可用。所以在这个回调里你不能再去做复杂的异步操作比如不要尝试new一个Thread去上传日志大概率线程还没跑完进程就没了不要用runOnUiThread更新界面界面栈可能已经乱了不要做网络请求、数据库写入等耗时操作除非你确认能抢在进程死亡前完成。比较稳妥的做法是在这个回调里尽量同步地把文件写入完成写入路径用应用私有目录然后尽快把控制权还给系统。有些实现会专门开一个子进程来收集日志这样可以做更多操作但复杂度高很多。个人项目的初期阶段我不建议一上来就搞双进程。此外主线程崩溃和子线程崩溃在用户感知上不一样。主线程崩溃后主界面直接不可用系统很快弹崩溃框并杀掉进程子线程崩溃则可能只是某个后台任务中断有时候甚至不弹框但线程依然终止了。不管哪种我们的全局处理器都能接到回调只是子线程崩溃后App进程可能还活着。这种情况写日志没问题但别误以为App一定会退出。另外一个重要限制如果是OOM导致的崩溃可能内存已经非常紧张你再去做字符串拼接和写文件可能再次触发OOM。所以采集信息时要注意控制内存占用优先保证最重要的堆栈能写下来设备信息可以精简一点。3. 手写一个崩溃捕获模块3.1 CrashHandler 核心代码实现直接上一个能用的Java版本Kotlin版本思路完全一样。这个类负责三件事注册、采集、落盘。public class CrashHandler implements Thread.UncaughtExceptionHandler { private static final String TAG CrashHandler; private static final CrashHandler INSTANCE new CrashHandler(); private Context mContext; private Thread.UncaughtExceptionHandler mDefaultHandler; private CrashHandler() { } public static CrashHandler getInstance() { return INSTANCE; } public void init(Context context) { mContext context.getApplicationContext(); mDefaultHandler Thread.getDefaultUncaughtExceptionHandler(); Thread.setDefaultUncaughtExceptionHandler(this); } Override public void uncaughtException(Thread thread, Throwable throwable) { boolean writeResult false; try { writeResult handleException(thread, throwable); } catch (Exception e) { android.util.Log.e(TAG, write crash log failed, e); } if (mDefaultHandler ! null) { mDefaultHandler.uncaughtException(thread, throwable); } else { android.os.Process.killProcess(android.os.Process.myPid()); System.exit(1); } } private boolean handleException(Thread thread, Throwable throwable) { if (mContext null || throwable null) { return false; } StringBuilder sb new StringBuilder(); sb.append(Crash Time: ) .append(new SimpleDateFormat(yyyy-MM-dd HH:mm:ss, Locale.CHINA) .format(new Date())) .append(\n); sb.append(Thread: ).append(thread.getName()) .append(, id).append(thread.getId()) .append(, priority).append(thread.getPriority()) .append(\n); sb.append(collectDeviceInfo()); String activityName ActivityStack.getInstance().getCurrentActivityName(); if (activityName ! null) { sb.append(CurrentActivity: ).append(activityName).append(\n); } sb.append(Exception: ).append(throwable.toString()).append(\n); sb.append(Stack Trace:\n); StringWriter sw new StringWriter(); PrintWriter pw new PrintWriter(sw); throwable.printStackTrace(pw); Throwable cause throwable.getCause(); if (cause ! null) { sb.append(Caused by: ).append(cause.toString()).append(\n); cause.printStackTrace(pw); } pw.flush(); sb.append(sw); pw.close(); return saveCrashLog(sb.toString()); } private boolean saveCrashLog(String content) { File dir new File(mContext.getExternalFilesDir(null) File.separator crash); if (!dir.exists()) { if (!dir.mkdirs()) { android.util.Log.e(TAG, create crash dir failed: dir.getAbsolutePath()); return false; } } String time new SimpleDateFormat(yyyyMMdd_HHmmss, Locale.CHINA) .format(new Date()); String fileName crash_ time _ System.currentTimeMillis() .txt; File file new File(dir, fileName); try { FileOutputStream fos new FileOutputStream(file); fos.write(content.getBytes(UTF-8)); fos.flush(); fos.close(); android.util.Log.i(TAG, crash log saved: file.getAbsolutePath()); return true; } catch (Exception e) { android.util.Log.e(TAG, save crash log failed, e); return false; } } private String collectDeviceInfo() { StringBuilder sb new StringBuilder(); sb.append(Device Brand: ).append(Build.BRAND).append(\n); sb.append(Device Model: ).append(Build.MODEL).append(\n); sb.append(Device Product: ).append(Build.PRODUCT).append(\n); sb.append(Android Version: ).append(Build.VERSION.RELEASE).append(\n); sb.append(SDK Level: ).append(Build.VERSION.SDK_INT).append(\n); try { PackageManager pm mContext.getPackageManager(); PackageInfo pi pm.getPackageInfo(mContext.getPackageName(), 0); sb.append(Version Name: ).append(pi.versionName).append(\n); sb.append(Version Code: ).append(pi.versionCode).append(\n); } catch (PackageManager.NameNotFoundException e) { // ignore } return sb.toString(); } }上面这个类已经够小项目用了。注意几个关键点保存文件用的是getExternalFilesDir(null)“crash”目录这个目录不需要存储权限系统文件管理器在特定版本上也能看到但普通Android文件管理器访问受限。后面会详细说路径选择。3.2 设备信息、版本信息和当前页面采集崩溃日志里如果只有堆栈很多问题还是看不出来。比如同一个崩溃出现在不同机型上可能修复方向完全不同。所以在保存时最好把Build的信息和App版本信息都带上。上面代码里的collectDeviceInfo已经采集了基础字段实际项目中你还可以加上内存状态、屏幕分辨率、语言区域、build type等。采集当前Activity信息常见做法是实现Application.ActivityLifecycleCallbacks。在onActivityResumed和onActivityPaused里维护一个栈记录当前最顶层的Activity名称。配合CrashHandler崩溃时就能知道用户正卡在哪个页面。这对定位页面专项问题非常有用。public class ActivityStack implements Application.ActivityLifecycleCallbacks { private static final ActivityStack INSTANCE new ActivityStack(); private String currentActivityName; public static ActivityStack getInstance() { return INSTANCE; } Override public void onActivityResumed(Activity activity) { currentActivityName activity.getClass().getName(); } public String getCurrentActivityName() { return currentActivityName; } Override public void onActivityPaused(Activity activity) { // 如果已经切到后台这里可以置空也可以保留最后页面 } // 其他回调方法留空实现 ... }注意使用ActivityLifecycleCallbacks需要在Application中registerActivityLifecycleCallbacks()注册。在崩溃发生时通过这个记录获取页面名能大大加快问题复现。很多崩溃是强依赖页面状态的比如某个页面初始化时读了一个空值如果你能快速定位到是哪个页面代码走查范围一下就缩小了。3.3 堆栈格式化与日志文件存储策略代码里使用了StringWriter和PrintWriter来获取完整堆栈。为什么不用throwable.getStackTrace()因为getStackTrace返回的是StackTraceElement数组输出格式要自己拼而且容易漏掉多层Caused By。而printStackTrace会把完整的“at xxx.xxx.xxx”行和Caused by链都展开格式和Android Studio控制台里看到的几乎一样排查起来最直观。文件命名方面用了时间戳加当前毫秒数拼一个长名字就是为了防止同一秒内发生多次崩溃时互相覆盖。如果你觉得文件名长保留到秒随机数也可以。存储内容我习惯用“键值对堆栈”的纯文本格式不搞JSON。原因是纯文本人类可读性最好用记事本打开就能看而且用grep、adb pull都方便。如果你要做自动化上报再额外提供一个parse方法就行。文件名可以带crash_前缀后续做全局搜索也方便。这里还要注意编码统一用UTF-8。有些老代码直接getBytes()不指定编码在Windows模拟器上可能乱码。Android默认UTF-8没问题但明确写出来更稳妥。写入时先拼好完整字符串再一次性写入文件避免多次流式写入导致文件碎片化也能减少部分写入失败的概率。4. 存到手机的路径规划与权限适配4.1 私有目录与公共目录怎么选标题说“存到手机”很多人第一反应是保存到Download目录用户一看就能找到。但这里有几个坑直接写公共目录在Android 10以上受分区存储限制而且需要存储权限涉及到用户授权弹窗起码得多几行代码。既然要服务稳定我建议默认还是写到App专属目录。存储位置需要权限应用卸载后文件管理器可见性适用场景/data/data/包名/files否删除普通用户不可见需要root/adb核心隐私日志/data/data/包名/cache否删除普通用户不可见缓存可能被系统清理/storage/emulated/0/Android/data/包名/files否Android 11仍可删除Android 11后系统文件管理器可见推荐/storage/emulated/0/Download或Documents6.0需存储权限10分区存储限制保留用户容易看到用户主动导出时才好用MediaStore公共目录API 29部分场景不需要保留用户媒体库可见保存图片、视频等媒体文件从表里能看出来最省事的其实是getExternalFilesDir()也就是Android/data/包名/files这个目录。它属于外部存储但又是应用专属目录不需要任何存储权限。Android 11开始系统文件管理器可以直接看到这个目录用户可以自行找到日志文件。虽然是应用卸载时会被删除但我们做崩溃日志本来就是给开发者看不需要长期留存在用户手机里。提示如果一定要给用户导出到Download不要直接用FileOutputStream写公共目录Android 10要用MediaStore.Downloads插入。文本文件在部分手机上并不总是出现在“下载”App里体验也不稳定。成熟的方案是崩溃日志默认写到外部私有目录然后在应用内提供“导出分享”按钮用FileProvider把日志分享出去。4.2 Android 10 分区存储影响分区存储从Android 10API 29开始强制Android 11也继续收紧。如果你直接把路径写死成Environment.getExternalStorageDirectory()/Download在targetSdk 30以上大概率会抛FileNotFoundException或者没有权限。不要试图用MANAGE_EXTERNAL_STORAGE来绕过那个权限Google审核很严格个人项目没必要。所以我的建议除非你的项目有特殊要求否则不需要申请任何存储权限。把所有日志写入context.getExternalFilesDir(null)/crash目录再配合FileProvider分享。这样targetSdk 33/34都安全Android 14也能跑。使用FileProvider导出日志时需要在Manifest里声明provider然后配置file_paths.xml允许导出外部文件目录下的crash目录。具体配置网上很多核心就是paths external-files-path namecrash pathcrash// /paths这样用户就能通过系统分享面板把日志文件发给开发者。如果你希望App内直接展示日志而不是借助系统文件管理器那就按5.2节的方法读取目录文件列表并展示这比让用户自己找文件靠谱得多。4.3 日志文件命名、防覆盖和清理策略崩溃日志如果不做清理长时间使用会累积大量文件。我的做法是每次新写入日志时记录一下时间如果当前目录下历史日志文件数量超过50或者总大小超过5MB就异步清理最早的日志。这个清理操作放在Application初始化时做一次就好不用在崩溃回调里做因为崩溃回调不宜做复杂操作。文件命名防覆盖也很重要。网上很多代码只用SimpleDateFormat精确到秒同一秒发生两次崩溃就会覆盖。建议加上System.currentTimeMillis()或者用UUID生成短串。文件名中的“_”和“-”这类字符在传输和分享时兼容性更好。使用最后修改时间倒序排列能快速分辨哪些文件是最新的也方便在App日志列表里优先展示最新的崩溃记录。5. 完整接入流程与快速验证5.1 在 Application 里完成初始化先定义一个自己的Application类public class MyApplication extends Application { Override public void onCreate() { super.onCreate(); CrashHandler.getInstance().init(this); registerActivityLifecycleCallbacks(ActivityStack.getInstance()); } }然后在AndroidManifest.xml的application节点上配置nameapplication android:name.MyApplication android:labelstring/app_name ... 初始化最好放在Application的onCreate最前面保证你的代码优先于业务逻辑注册。因为任何后续业务代码崩了都能被捕获到。注册完成后你可以用下面的方法快速验证在某个页面上加一个按钮点击时故意抛个异常findViewById(R.id.btn_crash).setOnClickListener(v - { throw new RuntimeException(manual test crash); });运行App点击按钮理论上会看到崩溃弹窗然后你通过Android Studio的Device File Explorer找到/storage/emulated/0/Android/data/com.example.yourapp/files/crash/crash_2025xxxx_xxxxxx.txt打开文件堆栈信息都在里面。这就说明接入成功了。如果第一次没看到日志别急着下结论先重点检查有没有在Manifest里配置Application的name以及CrashHandler的init有没有被调用到。5.2 在应用内展示和导出崩溃日志有时候你不想每次都用adb工具。还可以做一个“崩溃日志”页面列出所有history日志点击就能查看内容并支持复制和分享。这个功能对非技术用户也很友好遇到问题后用户打开App的日志页面复制日志发给你。读取目录文件的代码也不复杂File crashDir new File(getExternalFilesDir(null), crash); File[] files crashDir.listFiles(); if (files ! null) { Arrays.sort(files, (a, b) - Long.compare(b.lastModified(), a.lastModified())); }排序规则用最后修改时间倒序最新的排在最前。点击item后用TextView展示分享用FileProvider。这部分不是核心但能显著提升工具实用性。尤其当你做了一个给测试人员用的内部版本时测试同学不用连电脑直接在App里看到日志并反馈整个链路顺畅很多。6. 常见问题与排查技巧实录6.1 日志文件生成失败先确认目录有没有创建成功。很多人直接在代码里写new File(path)然后保存没有调用mkdirs()如果父目录不存在FileOutputStream会直接抛异常导致日志丢失。我在CrashHandler里特意做了dir.mkdirs()判断但如果你使用的是自定义路径建议也加上。另一个常见问题有些手机厂商的省电优化会更快杀掉崩溃进程。如果你的保存逻辑太复杂可能日志根本没写完进程就死了。实测解决办法是尽量精简采集内容先同步写入关键信息再考虑附加信息。如果你发现日志偶尔没生成先看看logcat里“crash log saved”或者“save crash log failed”的日志。也可以通过增加一个before-crash的文件标记来判断主动崩溃时有没有走到回调。6.2 写入慢和二次崩溃崩溃回调里的IO操作是同步的正常情况下写一个几KB的文本也就几毫秒问题不大。但如果设备存储I/O很慢或者你在日志里塞了一堆很长很长的字符串确实会造成明显的延迟。要控制在合理范围堆栈可以截断、设备信息字段保持精简、不要拼入超长业务数据。一次崩溃日志控制在10KB以内绝大多数设备都能很快写完。同时要小心二次崩溃。如果CrashHandler在保存过程中又抛出异常比如FileNotFoundException没有catch住的话系统默认处理器可能就拿不到异常了。所以handleException内部要尽量把每一个可能出错的环节都try/catch住保证原处理器一定能被调用。这个二次崩溃在线上很难发现一旦发生可能连系统崩溃弹窗都没有用户只会觉得App“卡死”了。6.3 日志看不到或导出失败普通App装在手机上以后Android/data/包名/files目录在Android 11的系统文件管理器里是可见的但Android 12以后部分机型会有特殊限制。如果用户反馈看不到文件最稳妥的方式是在App内做一个列表页通过FileProvider直接分享文件避免让用户自己找。此外如果你在调试时想通过adb pull注意部分设备对Android/data目录有限制。调试时可用adb pull /sdcard/Android/data/包名/files/crash如果提示Permission denied可以用adb shell run-as 包名 cat path来读取不过仅限于debuggable应用。如果你的应用是release版且没有开启debuggablerun-as也会被拒绝。这时候还是在应用内做日志列表更可靠。6.4 快速压测主动制造崩溃除了空指针崩溃还可以在测试页放几个按钮分别触发ArrayIndexOutOfBoundsException、NumberFormatException、StackOverflowError用于验证不同异常类型都能被捕获。注意StackOverflowError有风险可能会导致线程栈严重溢出但既然只是测试问题不大。还有一个技巧如果崩溃后App的进程没有退出可能是你的默认处理器没有被正确调用或者你在uncaughtException里返回了比如你没有转交默认处理器。这会让App处于“似死非死”的状态。所以我在代码里强调如果mDefaultHandler为空必须自己killProcess。否则一定要转交原始处理器。这个点很多初学者会踩不是bug的问题是设计问题。7. 我的实操心得与后续扩展思路这套手写崩溃捕获方案我在好几个中小项目里都直接跑过稳定度很高。有人会问既然有现成的第三方平台为什么还要折腾我的答案很简单自己写一遍你会真正理解崩溃上报的每一个环节知道数据从哪来、怎么存、怎么读。下次接第三方SDK遇到问题时脑子里是有模型的不会两眼一抹黑。实际项目里我也逐渐加了一些扩展比如把崩溃次数统计在SharedPreferences中下次启动时如果发现上次崩溃了弹个友好提示并附带“一键复制日志”按钮还有把日志上传到自建服务器用写文件的方式发送到服务端。如果需要处理native crash可以考虑接入Breakpad但那是另一套大工程了。最后分享一个我踩过的小坑早期为了实现“用户可见的日志”我把日志写进了公共下载目录并且申请了写存储权限。结果Android 10以后权限弹窗越来越多用户不同意日志就写不进去后来我改成外部私有目录内置日志列表体验马上顺畅。现在做崩溃日志我的默认方案永远是初始写入无需权限的App专属目录在需要给用户看的时候用FileProvider分享坚决不为存日志去申请存储权限。这个原则从Android 8用到Android 14没出过问题。希望你的崩溃排查之路能比以前的我更顺一点。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门