android和iOS平臺的崩潰捕獲和收集
要實現崩潰捕獲和收集的困難主要有這麼幾個:
1、如何捕獲崩潰(比如c++常見的野指標錯誤或是記憶體讀寫越界,當發生這些情況時程式不是異常退出了嗎,我們如何捕獲它呢)
2、如何獲取堆疊資訊(告訴我們崩潰是哪個函式,甚至是第幾行發生的,這樣我們才可能重現並修改問題)
3、將錯誤日誌上傳到指定伺服器(這個最好辦)
我們先進行一個簡單的綜述。會引發崩潰的程式碼本質上就兩類,一個是c++語言層面的錯誤,比如野指標,除零,記憶體訪問異常等等;另一類是未捕獲異常(Uncaught Exception),iOS下面最常見的就是objective-c的NSException(通過@throw丟擲,比如,NSArray訪問元素越界),android下面就是java丟擲的異常了。這些異常如果沒有在最上層try住,那麼程式就崩潰了。 無論是iOS還是android系統,其底層都是unix或者是類unix系統,對於第一類語言層面的錯誤,可以通過訊號機制來捕獲(signal或者是sigaction,不要跟qt的訊號插槽弄混了),即任何系統錯誤都會丟擲一個錯誤訊號,我們可以通過設定一個回撥函式,然後在回撥函式裡面列印併發送錯誤日誌。
一、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() { // 1 linux錯誤訊號捕獲 for (int i = 0; i < s_fatal_signal_num; ++i) { signal(s_fatal_signals[i], SignalHandler); } // 2 objective-c未捕獲異常的捕獲 NSSetUncaughtExceptionHandler(&HandleException); }
在遊戲的最開始呼叫InitCrashReport()函式來開啟崩潰捕獲。 註釋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系統支援backtrace,通過這個函式可以直接打印出程式崩潰的呼叫堆疊。優點是,什麼符號函式表都不需要,也不需要儲存釋出出去的對應版本,直接檢視崩潰堆疊。缺點是,不能打印出具體哪一行崩潰,很多問題知道了是哪個函式崩的,但是還是查不出是因為什麼崩的
3、日誌上傳,這個需要看實際需求,比如我們公司就是把崩潰資訊http post到一個php伺服器。這裡就不多做聲明瞭。
4、技巧---崩潰後程序保持執行狀態而不退出
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);
在崩潰處理函式上傳完日誌資訊後,呼叫上述程式碼,可以重新構建程式主迴圈。這樣,程式即便崩潰了,依然可以正常執行(當然,這個時候是處於不穩定狀態,但是由於手持遊戲和應用大多是短期操作,不會有掛機這種說法,所以穩定與否就無關緊要了)。玩家甚至感受不到崩潰。
這裡要在說明一個感念,那就是“可重入(reentrant)”。簡單來說,當我們的崩潰回撥函式是可重入的時候,那麼再次發生崩潰的時候,依然可以正常執行這個新的函式;但是如果是不可重入的,則無法執行(這個時候就徹底死了)。要實現上面描述的效果,並且還要保證回撥函式是可重入的幾乎不可能。所以,我測試的結果是,objective-c的異常觸發多少次都可以正常執行。但是如果多次觸發錯誤訊號,那麼程式就會卡死。 所以要慎重決定是否要應用這個技巧。
二、android崩潰捕獲和收集
1、android開啟崩潰捕獲
首先是java程式碼的崩潰捕獲,這個可以仿照最下面的完整程式碼寫一個UncaughtExceptionHandler,然後在所有的Activity的onCreate函式最開始呼叫
Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionHandler(this));
這樣,當發生崩潰的時候,就會自動呼叫UncaughtExceptionHandler的public void uncaughtException(Thread thread, Throwable exception)函式,其中的exception包含堆疊資訊,我們可以在這個函式裡面列印我們需要的資訊,並且上傳錯誤日誌
然後是重中之重,jni的c++程式碼如何進行崩潰捕獲。
void InitCrashReport()
{
CCLOG("InitCrashReport");
// Try to catch crashes...
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的設定,當崩潰發生的時候就會呼叫android_sigaction函式。這同樣是linux的訊號機制。 此處設定訊號回撥函式的程式碼跟iOS有點不同,這個只是同一個功能的兩種不同寫法,沒有本質區別。有興趣的可以google下兩者的區別。
2、列印堆疊
java語法可以直接通過exception獲取到堆疊資訊,但是jni程式碼不支援backtrace,那麼我們如何獲取堆疊資訊呢? 這裡有個我想嘗試的新方法,就是使用google breakpad,貌似它現在完整的跨平臺了(支援windows, mac, linux, iOS和android等),它自己實現了一套minidump,在android上面限制會小很多。 但是這個庫有些大,估計要加到我們的工程中不是一件非常容易的事,所以我們還是使用了簡潔的“傳統”方案。 思路是,當發生崩潰的時候,在回撥函式裡面呼叫一個我們在Activity寫好的靜態函式。在這個函式裡面通過執行命令獲取logcat的輸出資訊(輸出資訊裡面包含了jni的崩潰地址),然後上傳這個崩潰資訊。 當我們獲取到崩潰資訊後,可以通過arm-linux-androideabi-addr2line(具體可能不是這個名字,在android ndk裡面搜尋*addr2line,找到實際的程式)解析崩潰資訊。
jni的崩潰回撥函式如下:
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呼叫了java的一個函式,然後所有的處理都是在java層面完成。
java對應的函式實現如下:
public static void onNativeCrashed() {
// http://stackoverflow.com/questions/1083154/how-can-i-catch-sigsegv-segmentation-fault-and-get-a-stack-trace-under-jni-on-a
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發生崩潰的時候,原始的activity可能已經結束掉了。 這個新的activity實現如下:
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;
// It's probably one of these devices where some fool broke logcat.
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函式,這個函式通過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
004931f8
這個就是我們崩潰函式的地址, libhelloworld.so就是崩潰的動態庫。我們要使用addr2line對這個動態庫進行解析(注意要是obj/local目錄下的那個比較大的,含有符號檔案的動態庫,不是Libs目錄下比較小的,同時釋出版本時,這個動態庫也要儲存好,之後查log都要有對應的動態庫)。命令如下:
arm-linux-androideabi-addr2line.exe -e 動態庫名稱 崩潰地址
例如:
$ /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
得到的結果就是哪個cpp檔案第幾行崩潰。 如果動態庫資訊不對,返回的就是 ?:0