1、前言
崩溃日志栈收集是 Android 应用基础的功能,我们最常收集的崩溃信息通常来自于 java 或者 native。而游戏应用还会
包含一些脚本的崩溃,常见的有c#脚本和Lua脚本。
本文会围绕这4种崩溃类型,学习如何收集,以及这些平台崩溃处理的一些知识
2、Android Java 崩溃
2.1、java 里面线程崩溃了的整体流程
- jvm 将线程崩溃派发给对应线程的dispatchUncaughtException方法
- 判断线程是否有自己的uncaughtExceptionHandler,有则交给它处理。如果无则交给该线程所属的ThreadGroup处理(ThreadGroup继承了UncaughtExceptionHandler)
- ThreadGroup会继续将该异常抛给它的父ThreadGroup(如果有),如果没有父ThreadGroup,则派发给Thread.getDefaultUncaughtExceptionHandler去处理
- 上一步中Thread的defaultUncaughtExceptionHandler会在进程创建时初始化的时候(RuntimeInit#commonInit)设置一个默认的handler(KillApplicationHandler)。应用程序随时可以通过Thread.setDefaultUncaughtExceptionHandler改变这个默认的handler
2.2、java 崩溃日志抓取流程
通过上面我们知道了java崩溃的派发流程,我们可以设置自己的handler来处理jvm派发的崩溃信息(处理操作包括:崩溃栈信息收集、上传、打印等)。同时为了保证程序能正常的崩溃,我们在前面覆盖uncaughtExceptionHandler的时候先缓存系统默认的handler,在我们处理完崩溃信息后,将崩溃继续抛给系统的handler。这样就完成了java 崩溃日志抓取的的逻辑。
2.3、抓取示例
设置我们自己的handler
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
Thread.UncaughtExceptionHandler defaultUncaughtHandler = Thread.getDefaultUncaughtExceptionHandler(); if (defaultUncaughtHandler != null && !defaultUncaughtHandler.equals(mMyExcetionHandler)) { mDefaultUncaughtHandler = defaultUncaughtHandler; Thread.setDefaultUncaughtExceptionHandler(mMyExcetionHandler); } if (thread == null) { return; } Thread.UncaughtExceptionHandler uncaughtHandler = thread.getUncaughtExceptionHandler(); if (uncaughtHandler != null && !uncaughtHandler.equals(mMyExcetionHandler)) { mUncaughtHandler = uncaughtHandler; thread.setUncaughtExceptionHandler(mMyExcetionHandler); } |
我们就可以在mMyExcetionHandler里面收集异常信息了,注意:收集完异常信息,建议该异常抛给系统处理,因为此时应用已经无法正常运行或运行行为不正确。做法如下:
1 2 3 4 5 6 7 8 9 10 |
//... //我们自己的异常信息收集等处理逻辑 //crash如果被第三方的uncaughtException覆盖 if (mUncaughtHandler != null) { mUncaughtHandler.uncaughtException(Thread.currentThread(), ex); } if (mDefaultUncaughtHandler != null) { mDefaultUncaughtHandler.uncaughtException(Thread.currentThread(), ex); } |
2.3.1、捕获异常后想弹窗提示
也许你想在捕获到异常后弹窗提示给用户,你需要注意uncaughtException
方法里面是不能做UI操作的,你可以通过intent方式启动另一个activity来实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { @Override public void uncaughtException(Thread t, Throwable e) { Log.d("conio", "default uncaught exception"); //弹窗提示 ComponentName componentName = new ComponentName(MainActivity.this, DialogActivity.class); Intent intent = new Intent(); intent.setComponent(componentName); MainActivity.this.startActivity(intent); //退出应用 System.exit(0); } }); |
3、Native Crash 崩溃
3.1、Native Crash 怎么产生
与 Java 平台不同,C/C++ 没有一个通用的异常处理接口,在 C 层,CPU 通过异常中断的方式,触发异常处理流程。不同的处理器,有不同的异常中断类型和中断处理方式,linux 把这些中断处理,统一为信号量,每一种异常都有一个对应的信号,可以注册回调函数进行处理需要关注的信号量。
所有的信号量都定义在<signal.h>文件中,这里我将几乎全部的信号量以及所代表的含义都标注出来了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
#define SIGHUP 1 // 终端连接结束时发出(不管正常或非正常) #define SIGINT 2 // 程序终止(例如Ctrl-C) #define SIGQUIT 3 // 程序退出(Ctrl-\) #define SIGILL 4 // 执行了非法指令,或者试图执行数据段,堆栈溢出 #define SIGTRAP 5 // 断点时产生,由debugger使用 #define SIGABRT 6 // 调用abort函数生成的信号,表示程序异常 #define SIGIOT 6 // 同上,更全,IO异常也会发出 #define SIGBUS 7 // 非法地址,包括内存地址对齐出错,比如访问一个4字节的整数, 但其地址不是4的倍数 #define SIGFPE 8 // 计算错误,比如除0、溢出 #define SIGKILL 9 // 强制结束程序,具有最高优先级,本信号不能被阻塞、处理和忽略 #define SIGUSR1 10 // 未使用,保留 #define SIGSEGV 11 // 非法内存操作,与SIGBUS不同,他是对合法地址的非法访问,比如访问没有读权限的内存,向没有写权限的地址写数据 #define SIGUSR2 12 // 未使用,保留 #define SIGPIPE 13 // 管道破裂,通常在进程间通信产生 #define SIGALRM 14 // 定时信号, #define SIGTERM 15 // 结束程序,类似温和的SIGKILL,可被阻塞和处理。通常程序如果终止不了,才会尝试SIGKILL #define SIGSTKFLT 16 // 协处理器堆栈错误 #define SIGCHLD 17 // 子进程结束时, 父进程会收到这个信号。 #define SIGCONT 18 // 让一个停止的进程继续执行 #define SIGSTOP 19 // 停止进程,本信号不能被阻塞,处理或忽略 #define SIGTSTP 20 // 停止进程,但该信号可以被处理和忽略 #define SIGTTIN 21 // 当后台作业要从用户终端读数据时, 该作业中的所有进程会收到SIGTTIN信号 #define SIGTTOU 22 // 类似于SIGTTIN, 但在写终端时收到 #define SIGURG 23 // 有紧急数据或out-of-band数据到达socket时产生 #define SIGXCPU 24 // 超过CPU时间资源限制时发出 #define SIGXFSZ 25 // 当进程企图扩大文件以至于超过文件大小资源限制 #define SIGVTALRM 26 // 虚拟时钟信号. 类似于SIGALRM, 但是计算的是该进程占用的CPU时间. #define SIGPROF 27 // 类似于SIGALRM/SIGVTALRM, 但包括该进程用的CPU时间以及系统调用的时间 #define SIGWINCH 28 // 窗口大小改变时发出 #define SIGIO 29 // 文件描述符准备就绪, 可以开始进行输入/输出操作 #define SIGPOLL SIGIO // 同上,别称 #define SIGPWR 30 // 电源异常 #define SIGSYS 31 // 非法的系统调用 |
3.2、Native 崩溃信息抓取
通常我们在做 crash 收集的时候,主要关注这几个信号量:
1 |
const int signal_array[] = {SIGILL, SIGABRT, SIGBUS, SIGFPE, SIGSEGV, SIGSTKFLT, SIGSYS}; |
接收Native Crash崩溃信息:
可以用sigaction方法来接收native crash 崩溃信息回调
1 |
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact); |
函数说明:sigaction()会依参数signum 指定的信号编号来设置该信号的处理函数. 参数signum 可以指定SIGKILL 和SIGSTOP 以外的所有信号。如参数结构sigaction 定义如下:
第一个参数 int 类型,表示需要关注的信号量
第二个参数 sigaction 结构体指针,用于声明当某个特定信号发生的时候,应该如何处理。
第三个参数也是 sigaction 结构体指针,他表示的是默认处理方式,当我们自定义了信号量处理的时候,用他存储之前默认的处理方式。
所以,要订阅异常发生的信号,最简单的做法就是直接用一个循环遍历所有要订阅的信号,对每个信号调用sigaction()
1 2 3 4 5 6 7 |
void init() { struct sigaction handler; struct sigaction old_signal_handlers[SIGNALS_LEN]; for (int i = 0; i < SIGNALS_LEN; ++i) { sigaction(signal_array[i], &handler, & old_signal_handlers[i]); } } |
一个详细的Native 崩溃栈信息如下:
详细参考这篇文章:《Android Native Crash 收集》
4、C# 脚本崩溃收集
C#脚本未捕获的异常,与Android和Native未捕获异常很大的区别是,未捕获异常不会照成引用的闪退。所以,C#脚本的异常危害相对较小,但是同样更加容易存在在游戏中。而闪退问题能够及时发现并进行修复。C#脚本异常,抛出的时机不同,危害性也有所不同; 在Start、Awake等函数抛出的异常,会造成Update、OnGUI无法正常运行,游戏可能表现为无响应、图片确实等。Update、OnGUI的异常也一定会引起游戏逻辑及画面上的一些异常。
从测试角度,C#脚本未捕获的异常时一定需要报告给开发者的。
C#里面提供了两个方法可以接收处理脚本异常:
- AppDomain.CurrentDomain.UnhandledException
- Application.RegisterLogCallback或者Application.logMessageReceived
UnhandledException
如果是在默认域中注册,任何线程中抛出的未捕获异常均会触发这个未处理异常函数。
收到崩溃并提取崩溃信息的代码示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
private static void OnHandleUnresolvedException(object sender, System.UnhandledExceptionEventArgs args) { writeLog("OnHandleUnresolvedException:error occur"); if (args == null || args.ExceptionObject == null) return; try { if (args.ExceptionObject.GetType() != typeof(System.Exception)) return; } catch { if (UnityEngine.Debug.isDebugBuild == true) UnityEngine.Debug.Log("Failed to report uncaught exception"); return; } //UnityEngine.Debug.Log("{0}", sender.ToString()); _HandleException((System.Exception)args.ExceptionObject, null, true); } private static void _HandleException(System.Exception e, string message, bool uncaught) { if (e == null) return; string name = e.GetType().Name; string reason = e.Message; if (!string.IsNullOrEmpty(message)) { reason = string.Format("{0}{1}***{2}", reason, System.Environment.NewLine, message); } StringBuilder stackTraceBuilder = new StringBuilder(""); StackTrace stackTrace = new StackTrace(e, true); int count = stackTrace.FrameCount; for (int i = 0; i < count; i++) { StackFrame frame = stackTrace.GetFrame(i); stackTraceBuilder.AppendFormat("{0}.{1}", frame.GetMethod().DeclaringType.Name, frame.GetMethod().Name); ParameterInfo[] parameters = frame.GetMethod().GetParameters(); if (parameters == null || parameters.Length == 0) { stackTraceBuilder.Append(" () "); } else { stackTraceBuilder.Append(" ("); int pcount = parameters.Length; ParameterInfo param = null; for (int p = 0; p < pcount; p++) { param = parameters[p]; stackTraceBuilder.AppendFormat("{0} {1}", param.ParameterType.Name, param.Name); if (p != pcount - 1) { stackTraceBuilder.Append(", "); } } param = null; stackTraceBuilder.Append(") "); } string fileName = frame.GetFileName(); if (!string.IsNullOrEmpty(fileName) && !fileName.ToLower().Equals("unknown")) { fileName = fileName.Replace("\\", "/"); int loc = fileName.ToLower().IndexOf("/assets/"); if (loc < 0) { loc = fileName.ToLower().IndexOf("assets/"); } if (loc > 0) { fileName = fileName.Substring(loc); } stackTraceBuilder.AppendFormat("(at {0}:{1})", fileName, frame.GetFileLineNumber()); } stackTraceBuilder.AppendLine(); } } |
Application.RegisterLogCallback
在开发的过程中,为了防止游戏运行产生异常信息,可以借助unity为我们提供的log回调方法,判断运行过程中是否产生意外状况,比如游戏运行过程中产生一些异常错误,我们可以通过该方法,对异常进行处理。例如根据日志的TAG都会包含Unity,可以大致判断出这条日志是UnityEngine自身的接口打印的,在catch异常之后也是会调用Debug.LogError来输出日志的。所以,我们可以通过注册RegisterLogCallBack来获取到系统的调用。
另外C#还提供了另一个类似方法:RegisterLogCallbackThreaded,它们都是在一个日志信息上注册一个委托来被调用,这个函数和RegisterLogCallback唯一不同的是,这个函数将从不同的线程被调用,注意:你只有你知道你在做什么时才能使用这个函数,否则使用Application.RegisterLogCallback。
在使用RegisterLogCallback方法时还需要特别注意:检查是否有其他存在注册Application.RegisterLogCallback(LogCallback)的逻辑,由于系统默认的LogCallback是单播实现,所以只能维持一个回调实例。
4.1、代码示例
监听崩溃异常信息回调:
1 2 3 4 5 6 7 8 9 |
System.AppDomain.CurrentDomain.UnhandledException += new System.UnhandledExceptionEventHandler(OnHandleUnresolvedException); #if UNITY_5 || UNITY_5_3_OR_NEWER writeLog("ABOVE UNITY5"); Application.logMessageReceived += RegisterLogFunction; #else writeLog("UNDER UNITY5"); Application.RegisterLogCallback(RegisterLogFunction); #endif |
注意:在unity5.0版本以上RegisterLogCallback的单播实现变为了可以注册多个回调。
我们可以在Unity的MonoBehaviour子类的Start()方法里面初始化上面的内容。
5、Lua 脚本崩溃
lua 里面获取脚本执行过程中的异常信息是通过pcall的执行结果来实现的。我们来看下lua的pcall方法介绍:
[-(nargs + 1), +(nresults|1), –]
1 int <a class="funcname" href="http://pgl.yoyo.org/luai/i/lua_pcall">lua_pcall</a> (<a class="funcname" href="http://pgl.yoyo.org/luai/i/lua_State">lua_State</a> *L, int nargs, int nresults, int errfunc);Calls a function in protected mode.
Both
nargs
andnresults
have the same meaning as inlua_call
. If there are no errors during the call,lua_pcall
behaves exactly likelua_call
. However, if there is any error,lua_pcall
catches it, pushes a single value on the stack (the error message), and returns an error code. Likelua_call
,lua_pcall
always removes the function and its arguments from the stack.If
errfunc
is 0, then the error message returned on the stack is exactly the original error message. Otherwise,errfunc
is the stack index of an error handler function. (In the current implementation, this index cannot be a pseudo-index.) In case of runtime errors, this function will be called with the error message and its return value will be the message returned on the stack bylua_pcall
.Typically, the error handler function is used to add more debug information to the error message, such as a stack traceback. Such information cannot be gathered after the return of
lua_pcall
, since by then the stack has unwound.The
lua_pcall
function returns 0 in case of success or one of the following error codes (defined inlua.h
):
lua对pcall封装了lua和c两种语言的实现,我们分别对这两种接口介绍如何抓取lua脚本崩溃日志信息:
5.1、Lua实现
我们用一个例子介绍在lua里面抓取执行异常,在java里面有try..catch可以抓住某段代码执行的异常信息,但lua原生并没有提供try-catch的语法来捕获异常处理,但是提供了pcall/xpcall
等接口,可在保护模式下执行lua函数。
因此,可以通过封装这两个接口,来实现try-catch块的捕获机制。
我们可以先来看下,封装后的try-catch使用方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
try { -- try 代码块 function () error("error message") end, -- catch 代码块 catch { -- 发生异常后,被执行 function (errors) print(errors) end } } |
上面的代码中,在try块内部认为引发了一个异常,并且抛出错误消息,在catch中进行了捕获,并且将错误消息进行输出显示。
这里除了对pcall/xpcall
进行了封装,用来捕获异常信息,还利用了lua的函数调用语法特性,在只有一个参数传递的情况下,lua可以直接传递一个table类型,并且省略()
其实try后面的整个{...}
都是一个table而已,作为参数传递给了try函数,其具体实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
function try(block) -- get the try function local try = block[1] assert(try) -- get catch and finally functions local funcs = block[2] if funcs and block[3] then table.join2(funcs, block[2]) end -- try to call it local ok, errors = pcall(try) if not ok then -- run the catch function if funcs and funcs.catch then funcs.catch(errors) end end -- run the finally function if funcs and funcs.finally then funcs.finally(ok, errors) end -- ok? if ok then return errors end end |
可以看到这里用了pcall
来实际调用try块里面的函数,这样就算函数内部出现异常,也不会中断程序,pcall
会返回false表示运行失败。同时我们也可以使用 debug.traceback方法获得当前调用栈的栈回溯信息。
traceback的函数介绍如下:
traceback ([thread,] [message [, level]]):如果 message 有,且不是字符串或 nil, 函数不做任何处理直接返回 message。 否则,它返回调用栈的栈回溯信息。 字符串可选项 message 被添加在栈回溯信息的开头。 数字可选项 level 指明从栈的哪一层开始回溯 (默认为 1 ,即调用 traceback 的那里)。
5.2、C 实现
lua 提供了 C的接口让它接入android,unity,ios等其它平台成为可能,例如:android可以通过native方法调到lua的c的接口,再通过lua的c库的pcall接口来执行lua脚本。
lua的C库中执行lua脚本并设置异常处理函数的代码示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
const char *luaCode = (*env)->GetStringUTFChars(env, luaCode_, 0); lua_State* L = (lua_State*)luaState; //将一个 C 函数压入堆栈。 这个函数接收一个 C 函数指针, //并将一个类型为 function 的 Lua 值 压入堆栈。当这个栈顶的值被调用时,将触发对应的 C 函数。 lua_pushcfunction(L, msghandler); //我们通过luaL_loadstring,将所有代码读入lua,并且检查代码是否有语法错误 int status = luaL_loadstring(L, luaCode); if (status == LUA_OK) { //1.首先lua_pcall方法会压到L这个栈里 //2.第二个参数为参数的个数,这里是0个参数。方法调用的时候所有的参数和该方法都从栈里面pop出来 //3.第三个参数代表结果的个数,这里LUA_MULTRET代表不限制返回的个数 //4.第四个参数如果是0,返回到栈里的是原始的错误信息,不然这个数字就是错误处理函数在栈里的索引,这里指的是msghandler status = lua_pcall(L, 0, LUA_MULTRET, 1); } const char* ret = ""; if (status != LUA_OK) { ret = lua_tostring(L, -1); } lua_settop(L, 0); (*env)->ReleaseStringUTFChars(env, luaCode_, luaCode); return (*env)->NewStringUTF(env, ret); |
6、崩溃日志分析工具
上面介绍了这么多语言的崩溃日志抓取的方法,介绍一个崩溃日志分析工具——LogAnalyzer