做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或Documents | 6.0+需存储权限;10+分区存储限制 | 保留 | 用户容易看到 | 用户主动导出时才好用 |
| MediaStore公共目录(API 29+) | 部分场景不需要 | 保留 | 用户媒体库可见 | 保存图片、视频等媒体文件 |
从表里能看出来,最省事的其实是getExternalFilesDir(),也就是Android/data/包名/files这个目录。它属于外部存储但又是应用专属目录,不需要任何存储权限。Android 11开始,系统文件管理器可以直接看到这个目录,用户可以自行找到日志文件。虽然是应用卸载时会被删除,但我们做崩溃日志本来就是给开发者看,不需要长期留存在用户手机里。
提示:如果一定要给用户导出到Download,不要直接用FileOutputStream写公共目录,Android 10+要用MediaStore.Downloads插入。文本文件在部分手机上并不总是出现在“下载”App里,体验也不稳定。成熟的方案是:崩溃日志默认写到外部私有目录,然后在应用内提供“导出分享”按钮,用FileProvider把日志分享出去。
4.2 Android 10+ 分区存储影响
分区存储从Android 10(API 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 name="crash" path="crash/"/> </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节点上配置name:
<application android:name=".MyApplication" android:label="@string/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版且没有开启debuggable,run-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,没出过问题。希望你的崩溃排查之路,能比以前的我更顺一点。