通過崩潰捕獲和收集,可以收集到已發(fā)布應用(游戲)的異常,以便開發(fā)人員發(fā)現(xiàn)和修改bug,對于提高軟件質(zhì)量有著極大的幫助。本文介紹了iOS和
android平臺下崩潰捕獲和收集的原理及步驟,不過如果是個人開發(fā)應用或者沒有特殊限制的話,就不用往下看了,直接把友盟sdk(一個統(tǒng)計分析
sdk)加入到工程中就萬事大吉了,其中的錯誤日志功能完全能夠滿足需求,而且不需要額外準備接收服務器。
但是如果你對其原理更感興趣,或者像我一樣必須要兼容公司現(xiàn)有的bug收集系統(tǒng),那么下面的東西就值得一看了。
要實現(xiàn)崩潰捕獲和收集的困難主要有這么幾個:
1、如何捕獲崩潰(比如c++常見的野指針錯誤或是內(nèi)存讀寫越界,當發(fā)生這些情況時程序不是異常退出了嗎,我們?nèi)绾尾东@它呢)
2、如何獲取堆棧信息(告訴我們崩潰是哪個函數(shù),甚至是第幾行發(fā)生的,這樣我們才可能重現(xiàn)并修改問題)
3、將錯誤日志上傳到指定服務器(這個最好辦)
我們先進行一個簡單的綜述。會引發(fā)崩潰的代碼本質(zhì)上就兩類,一個是c++語言層面的錯誤,比如野指針,除零,內(nèi)存訪問異常等等;另一類是未捕獲異常
(Uncaught
Exception),iOS下面最常見的就是objective-c的NSException(通過@throw拋出,比如,NSArray訪問元素越
界),android下面就是java拋出的異常了。這些異常如果沒有在最上層try住,那么程序就崩潰了。
無論是iOS還是android系統(tǒng),其底層都是unix或者是類unix系統(tǒng),對于第一類語言層面的錯誤,可以通過信號機制來捕獲(signal或者
是sigaction,不要跟qt的信號插槽弄混了),即任何系統(tǒng)錯誤都會拋出一個錯誤信號,我們可以通過設定一個回調(diào)函數(shù),然后在回調(diào)函數(shù)里面打印并發(fā)
送錯誤日志。
一、iOS平臺的崩潰捕獲和收集
1、設置開啟崩潰捕獲
- static int s_fatal_signals[] = {
- SIGABRT,
- SIGBUS,
- SIGFPE,
- SIGILL,
- SIGSEGV,
- SIGTRAP,
- SIGTERM,
- SIGKILL,
- };
-
- static const char* s_fatal_signal_names[] = {
- "SIGABRT",
- "SIGBUS",
- "SIGFPE",
- "SIGILL",
- "SIGSEGV",
- "SIGTRAP",
- "SIGTERM",
- "SIGKILL",
- };
-
- static int s_fatal_signal_num = sizeof(s_fatal_signals) / sizeof(s_fatal_signals[0]);
-
- void InitCrashReport()
- {
-
- for (int i = 0; i < s_fatal_signal_num; ++i) {
- signal(s_fatal_signals[i], SignalHandler);
- }
-
-
- NSSetUncaughtExceptionHandler(&HandleException);
- }
在游戲的最開始調(diào)用InitCrashReport()函數(shù)來開啟崩潰捕獲。 注釋1處對應上文所說的第一類崩潰,注釋2處對應objective-c(或者說是UIKit Framework)拋出但是沒有被處理的異常。
2、打印堆棧信息
- + (NSArray *)backtrace
- {
- void* callstack[128];
- int frames = backtrace(callstack, 128);
- char **strs = backtrace_symbols(callstack, frames);
-
- int i;
- NSMutableArray *backtrace = [NSMutableArray arrayWithCapacity:frames];
- for (i = kSkipAddressCount;
- i < __min(kSkipAddressCount + kReportAddressCount, frames);
- ++i) {
- [backtrace addObject:[NSString stringWithUTF8String:strs[i]]];
- }
- free(strs);
-
- return backtrace;
- }
幸好,蘋果的iOS系統(tǒng)支持backtrace,通過這個函數(shù)可以直接打印出程序崩潰的調(diào)用堆棧。優(yōu)點是,什么符號函數(shù)表都不需要,也不需要保存發(fā)布出去
的對應版本,直接查看崩潰堆棧。缺點是,不能打印出具體哪一行崩潰,很多問題知道了是哪個函數(shù)崩的,但是還是查不出是因為什么崩的
3、日志上傳,這個需要看實際需求,比如我們公司就是把崩潰信息http post到一個php服務器。這里就不多做聲明了。
4、技巧---崩潰后程序保持運行狀態(tài)而不退出
- CFRunLoopRef runLoop = CFRunLoopGetCurrent();
- CFArrayRef allModes = CFRunLoopCopyAllModes(runLoop);
-
- while (!dismissed)
- {
- for (NSString *mode in (__bridge NSArray *)allModes)
- {
- CFRunLoopRunInMode((__bridge CFStringRef)mode, 0.001, false);
- }
- }
-
- CFRelease(allModes);
在崩潰處理函數(shù)上傳完日志信息后,調(diào)用上述代碼,可以重新構(gòu)建程序主循環(huán)。這樣,程序即便崩潰了,依然可以正常運行(當然,這個時候是處于不穩(wěn)定狀態(tài),但是由于手持游戲和應用大多是短期操作,不會有掛機這種說法,所以穩(wěn)定與否就無關緊要了)。玩家甚至感受不到崩潰。
這里要在說明一個感念,那就是“可重入(reentrant)”。
簡單來說,當我們的崩潰回調(diào)函數(shù)是可重入的時候,那么再次發(fā)生崩潰的時候,依然可以正常運行這個新的函數(shù);但是如果是不可重入的,則無法運行(這個時候就
徹底死了)。要實現(xiàn)上面描述的效果,并且還要保證回調(diào)函數(shù)是可重入的幾乎不可能。所以,我測試的結(jié)果是,objective-c的異常觸發(fā)多少次都可以正
常運行。但是如果多次觸發(fā)錯誤信號,那么程序就會卡死。
所以要慎重決定是否要應用這個技巧。
二、android崩潰捕獲和收集
1、android開啟崩潰捕獲
首先是java代碼的崩潰捕獲,這個可以仿照最下面的完整代碼寫一個UncaughtExceptionHandler,然后在所有的Activity的onCreate函數(shù)最開始調(diào)用
Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionHandler(this));
這樣,當發(fā)生崩潰的時候,就會自動調(diào)用UncaughtExceptionHandler的public void
uncaughtException(Thread thread, Throwable
exception)函數(shù),其中的exception包含堆棧信息,我們可以在這個函數(shù)里面打印我們需要的信息,并且上傳錯誤日志
然后是重中之重,jni的c++代碼如何進行崩潰捕獲。
- void InitCrashReport()
- {
- CCLOG("InitCrashReport");
-
-
- struct sigaction handler;
- memset(&handler, 0, sizeof(struct sigaction));
-
- handler.sa_sigaction = android_sigaction;
- handler.sa_flags = SA_RESETHAND;
-
- #define CATCHSIG(X) sigaction(X, &handler, &old_sa[X])
- CATCHSIG(SIGILL);
- CATCHSIG(SIGABRT);
- CATCHSIG(SIGBUS);
- CATCHSIG(SIGFPE);
- CATCHSIG(SIGSEGV);
- CATCHSIG(SIGSTKFLT);
- CATCHSIG(SIGPIPE);
- }
通過singal的設置,當崩潰發(fā)生的時候就會調(diào)用android_sigaction函數(shù)。這同樣是linux的信號機制。 此處設置信號回調(diào)函數(shù)的代碼跟iOS有點不同,這個只是同一個功能的兩種不同寫法,沒有本質(zhì)區(qū)別。有興趣的可以google下兩者的區(qū)別。
2、打印堆棧
java語法可以直接通過exception獲取到堆棧信息,但是jni代碼不支持backtrace,那么我們?nèi)绾潍@取堆棧信息呢?
這里有個我想嘗試的新方法,就是使用google breakpad,貌似它現(xiàn)在完整的跨平臺了(支持windows, mac, linux,
iOS和android等),它自己實現(xiàn)了一套minidump,在android上面限制會小很多。
但是這個庫有些大,估計要加到我們的工程中不是一件非常容易的事,所以我們還是使用了簡潔的“傳統(tǒng)”方案。
思路是,當發(fā)生崩潰的時候,在回調(diào)函數(shù)里面調(diào)用一個我們在Activity寫好的靜態(tài)函數(shù)。在這個函數(shù)里面通過執(zhí)行命令獲取logcat的輸出信息(輸出
信息里面包含了jni的崩潰地址),然后上傳這個崩潰信息。
當我們獲取到崩潰信息后,可以通過arm-linux-androideabi-addr2line(具體可能不是這個名字,在android
ndk里面搜索*addr2line,找到實際的程序)解析崩潰信息。
jni的崩潰回調(diào)函數(shù)如下:
- void android_sigaction(int signal, siginfo_t *info, void *reserved)
- {
- if (!g_env) {
- return;
- }
-
- jclass classID = g_env->FindClass(CLASS_NAME);
- if (!classID) {
- return;
- }
-
- jmethodID methodID = g_env->GetStaticMethodID(classID, "onNativeCrashed", "()V");
- if (!methodID) {
- return;
- }
-
- g_env->CallStaticVoidMethod(classID, methodID);
-
- old_sa[signal].sa_handler(signal);
- }
可以看到,我們僅僅是通過jni調(diào)用了java的一個函數(shù),然后所有的處理都是在java層面完成。
java對應的函數(shù)實現(xiàn)如下:
- public static void onNativeCrashed() {
-
- Log.e("handller", "handle");
- new RuntimeException("crashed here (native trace should follow after the Java trace)").printStackTrace();
- s_instance.startActivity(new Intent(s_instance, CrashHandler.class));
- }
我們開啟了一個新的activity,因為當jni發(fā)生崩潰的時候,原始的activity可能已經(jīng)結(jié)束掉了。 這個新的activity實現(xiàn)如下:
- public class CrashHandler extends Activity
- {
- public static final String TAG = "CrashHandler";
- protected void onCreate(Bundle state)
- {
- super.onCreate(state);
- setTitle(R.string.crash_title);
- setContentView(R.layout.crashhandler);
- TextView v = (TextView)findViewById(R.id.crashText);
- v.setText(MessageFormat.format(getString(R.string.crashed), getString(R.string.app_name)));
- final Button b = (Button)findViewById(R.id.report),
- c = (Button)findViewById(R.id.close);
- b.setOnClickListener(new View.OnClickListener(){
- public void onClick(View v){
- final ProgressDialog progress = new ProgressDialog(CrashHandler.this);
- progress.setMessage(getString(R.string.getting_log));
- progress.setIndeterminate(true);
- progress.setCancelable(false);
- progress.show();
- final AsyncTask task = new LogTask(CrashHandler.this, progress).execute();
- b.postDelayed(new Runnable(){
- public void run(){
- if (task.getStatus() == AsyncTask.Status.FINISHED)
- return;
-
- progress.dismiss();
- task.cancel(true);
- new AlertDialog.Builder(CrashHandler.this)
- .setMessage(MessageFormat.format(getString(R.string.get_log_failed), getString(R.string.author_email)))
- .setCancelable(true)
- .setIcon(android.R.drawable.ic_dialog_alert)
- .show();
- }}, 3000);
- }});
- c.setOnClickListener(new View.OnClickListener(){
- public void onClick(View v){
- finish();
- }});
- }
-
- static String getVersion(Context c)
- {
- try {
- return c.getPackageManager().getPackageInfo(c.getPackageName(),0).versionName;
- } catch(Exception e) {
- return c.getString(R.string.unknown_version);
- }
- }
- }
-
- class LogTask extends AsyncTask<Void, Void, Void>
- {
- Activity activity;
- String logText;
- Process process;
- ProgressDialog progress;
-
- LogTask(Activity a, ProgressDialog p) {
- activity = a;
- progress = p;
- }
-
- @Override
- protected Void doInBackground(Void... v) {
- try {
- Log.e("crash", "doInBackground begin");
- process = Runtime.getRuntime().exec(new String[]{"logcat","-d","-t","500","-v","threadtime"});
- logText = UncaughtExceptionHandler.readFromLogcat(process.getInputStream());
- Log.e("crash", "doInBackground end");
- } catch (IOException e) {
- e.printStackTrace();
- Toast.makeText(activity, e.toString(), Toast.LENGTH_LONG).show();
- }
- return null;
- }
-
- @Override
- protected void onCancelled() {
- Log.e("crash", "onCancelled");
- process.destroy();
- }
-
- @Override
- protected void onPostExecute(Void v) {
- Log.e("crash", "onPostExecute");
- progress.setMessage(activity.getString(R.string.starting_email));
- UncaughtExceptionHandler.sendLog(logText, activity);
- progress.dismiss();
- activity.finish();
- Log.e("crash", "onPostExecute over");
- }
最主要的地方是doInBackground函數(shù),這個函數(shù)通過logcat獲取了崩潰信息。 不要忘記在AndroidManifest.xml添加讀取LOG的權限
- <uses-permission android:name="android.permission.READ_LOGS" />
3、獲取到錯誤日志后,就可以寫到sd卡(同樣不要忘記添加權限),或者是上傳。 代碼很容易google到,不多說了。 最后再說下如何解析這個錯誤日志。
我們在獲取到的錯誤日志中,可以截取到如下信息:
- 12-12 20:41:31.807 24206 24206 I DEBUG :
- 12-12 20:41:31.847 24206 24206 I DEBUG : #00 pc 004931f8 /data/data/org.cocos2dx.wing/lib/libhelloworld.so
- 12-12 20:41:31.847 24206 24206 I DEBUG : #01 pc 005b3a5e /data/data/org.cocos2dx.wing/lib/libhelloworld.so
- 12-12 20:41:31.847 24206 24206 I DEBUG : #02 pc 005aab68 /data/data/org.cocos2dx.wing/lib/libhelloworld.so
- 12-12 20:41:31.847 24206 24206 I DEBUG : #03 pc 005ad8aa /data/data/org.cocos2dx.wing/lib/libhelloworld.so
- 12-12 20:41:31.847 24206 24206 I DEBUG : #04 pc 005924a4 /data/data/org.cocos2dx.wing/lib/libhelloworld.so
- 12-12 20:41:31.847 24206 24206 I DEBUG : #05 pc 005929b6 /data/data/org.cocos2dx.wing/lib/libhelloworld.so
這
個就是我們崩潰函數(shù)的地址,
libhelloworld.so就是崩潰的動態(tài)庫。我們要使用addr2line對這個動態(tài)庫進行解析(注意要是obj/local目錄下的那個比較
大的,含有符號文件的動態(tài)庫,不是Libs目錄下比較小的,同時發(fā)布版本時,這個動態(tài)庫也要保存好,之后查log都要有對應的動態(tài)庫)。命令如下:
arm-linux-androideabi-addr2line.exe -e 動態(tài)庫名稱 崩潰地址
例如:
- $ /cygdrive/d/devandroid/android-ndk-r8c-windows/android-ndk-r8c/toolchains/arm-linux-androideabi-4.6/prebuilt/windows/bin/arm-linux-androideabi-addr2line.exe -e obj/local/armeabi-v7a/libhelloworld.so 004931f8
得到的結(jié)果就是哪個cpp文件第幾行崩潰。 如果動態(tài)庫信息不對,返回的就是 ?:0