MacとかWindowsでアプリが予期せぬ不具合で強制終了した後におもむろに出てくる「バグ報告」。
あれがSimejiにも欲しい。と思って作ってみたら予想以上に役に立ったので、ここでそのシステムを公開します。
アプリ開発者は絶対入れた方がいいですよ。ユーザの協力が得られればアプリの安定性向上に役に立つ事間違いなしです。
JavaにはNullPointerExceptionなどのcatchしなくてもclass load validationを素通りできる例外があります。
バグの多くはそういった例外を考慮しないことのようです。
なので、今回はそういった例外の「IndexOutOfBoundsException」を発生させます。

ボタンをタップすると例外が発生します。
oobBtn.setOnClickListener(new View.OnClickListener(){ public void onClick(View v) { int index = 5; String[] strs = new String[index]; String str = strs[index];//ここでIndexOutOfBoundsException }});
IndexOutOfBoundsExceptionも例外処理を記述しなくてもコンパイルエラーにならない例外です。
この仕組みのおかげでプログラムが書きやすくなっていますが、バグも入りやすいので要注意です。
ボタンをタップすると例外が発生するのですがcatchされずにアプリが強制終了します。

どこでこの例外が発生したのかが分かると、多くの問題は解決可能です。
これを捕捉するシステムを考えるのが本エントリの目的です。
Javaにはcatchしなかった例外を補足するThread.UncaughtExceptionHandlerという仕組みが元々備わっています。
今回はコレを利用します。
public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Context context = getApplicationContext(); //修正 @2010/06/29 Thread.setDefaultUncaughtExceptionHandler(new MyUncaughtExceptionHandler(context)); //修正 @2010/06/29 }
*アプリにつき1回だけハンドラを登録するだけでOKです。スレッドは関係ありません。(追記: 2010/01/23)
MyUncaughtExceptionHandlerの引数にActivityを渡すとメモリリークする可能性があります。(修正: 2010/06/29, via @95kugo)
onCreateメソッドの中でUncaughtExceptionHandlerを登録しておきます。
このハンドラは独自に拡張したMyUncaughtExceptionHandlerです。
どこにもcatchされなかった例外は、最終的にこのハンドラに渡されますので、
捕捉できなかった例外をハンドラ内で処理します。
public class MyUncaughtExceptionHandler implements UncaughtExceptionHandler { @Override public void uncaughtException(Thread th, Throwable e) { //catchされなかった例外は最終的にココに渡される } }
catchされなかった例外は、最終的にuncaughtExceptionメソッドの引数に渡されコールバックされます。
そのため、このメソッド内にバグ報告の処理を書きます。
引数のThrowableから、例外発生点のスタックトレースを取り出します。
StackTraceElement[] stacks = e.getStackTrace();
開発者にとって、例外発生経路と発生点はバグ修正するにあたり、とても役立つ情報です。
この情報(バグ情報)をサーバなどに送信し、開発者に通知します。
以上がAndroidにおけるバグ報告システムの概要でした。とても簡単です。
続いて、Simejiで使っている具体的なシステムをご紹介します。
あくまで一例なので、他に良い方法があるかもしれません。
良いアイデアがあれば是非教えて下さい。コメント、Twitter待ってます^^
- catchしていない例外が発生した場合、そのスタックトレースを外部記憶装置(SDカード等)に保存
- アプリ起動時にSDカード内にバグ情報ファイルが存在する場合は、その内容を送信
設計方針の理由は単純です。なんらかの理由でアプリが終了しようとしているので、
スタックトレースなどの情報をアプリ内部に持たせるのは不可能(もしくは崩れるかもしれない)なのでSDカードに保存します。
また、同じ理由で、次の起動時にファイルをチェックし、ファイルが存在すればバグ情報を送信するようにしました。
まずは、catchされなかった例外が発生した時に、そのスタックトレースをSDカードに保存します。
public void uncaughtException(Thread th, Throwable t) { //catchされなかった例外処理 try { saveState(t);//ここでスタックトレースを保存 } catch (FileNotFoundException e) { e.printStackTrace(); } } private void saveState(Throwable e) throws FileNotFoundException { StackTraceElement[] stacks = e.getStackTrace();//スタックトレース File file = BUG_REPORT_FILE;//保存先 PrintWriter pw = null; pw = new PrintWriter(new FileOutputStream(file)); StringBuilder sb = new StringBuilder(); int len = stacks.length; for (int i = 0; i < len; i++) { StackTraceElement stack = stacks[i]; sb.setLength(0); sb.append(stack.getClassName()).append("#");//クラス名 sb.append(stack.getMethodName()).append(":");//メソッド名 sb.append(stack.getLineNumber());//行番号 pw.println(sb.toString());//ファイル書出し } pw.close(); }
例外発生時のスタックトレースはBUG_REPORT_FILEに保存しています。
BUG_REPORT_FILEの中身は以下の通りです。
private static File BUG_REPORT_FILE = null; static { String sdcard = Environment.getExternalStorageDirectory().getPath(); String path = sdcard + File.separator + "bug.txt"; BUG_REPORT_FILE = new File(path); }
「/sdcard/bug.txt」というファイルに書出しています。
外部ストレージが/sdcardにマウントされるかは実装次第なので、ちょっと煩わしいですがEnvironmentを経由させておきます。
これで、catchされなかった例外が発生したスタックトレースを保存できました。
次に、アプリ起動時に、この情報をサーバに報告する部分を説明します。
アプリ起動時に、先ほどのバグ情報ファイルが存在するかをチェックし、
存在するならその内容をサーバに送信し、存在しないなら無視してアプリを起動します。
public void onStart(){ super.onStart(); //前回バグで強制終了した場合はダイアログ表示 MyUncaughtExceptionHandler.showBugReportDialogIfExist(); } public static final void showBugReportDialogIfExist() { File file = BUG_REPORT_FILE; if (file != null & file.exists()) { AlertDialog.Builder builder = new AlertDialog.Builder(sContext); builder.setTitle("バグレポート"); builder.setMessage("バグ発生状況を開発者に送信しますか?"); builder.setNegativeButton("Cancel", new OnClickListener(){ public void onClick(DialogInterface dialog, int which) { finish(dialog);//ダイアログの消去とファイルの削除 }}); builder.setPositiveButton("Post", new OnClickListener(){ public void onClick(DialogInterface dialog, int which) { postBugReportInBackground();//バグ報告(別スレッドでファイルを削除) dialog.dismiss(); }}); AlertDialog dialog = builder.create(); dialog.show(); } }
*SDカード内のファイル操作をマルチスレッドを無視した実装になっていましたので修正しました。(修正:2010/01/23 via. @mokkouyou)
ユーザの許可無くサーバに情報を送信するのはevilに感じるので、
ダイアログを表示し、送信の許可をユーザに求めます。

Postボタンがそれです。
PostボタンがタップされるとpostBugReportInBackgroundメソッドでバグ情報をサーバに送信します。
private static void postBugReportInBackground() { new Thread(new Runnable(){ public void run() { postBugReport(); BUG_REPORT_FILE.delete(); }}).start(); } private static void postBugReport() { List<NameValuePair> nvps = new ArrayList<NameValuePair>(); String bug = getFileBody(BUG_REPORT_FILE); nvps.add(new BasicNameValuePair("dev", Build.DEVICE)); nvps.add(new BasicNameValuePair("mod", Build.MODEL)); nvps.add(new BasicNameValuePair("sdk", Build.VERSION.SDK)); nvps.add(new BasicNameValuePair("ver", sPackInfo.versionName)); nvps.add(new BasicNameValuePair("bug", bug)); try { HttpPost httpPost = new HttpPost("http://foo.bar.org/bug"); httpPost.setEntity(new UrlEncodedFormEntity(nvps, HTTP.UTF_8)); DefaultHttpClient httpClient = new DefaultHttpClient(); httpClient.execute(httpPost); } catch (IOException e) { e.printStackTrace(); } }
HTTP通信は、ある程度の処理時間がかかりますので、別スレッドで実行するようにしています。
見た目上のレスポンス性能は重要です。
getFileBodyメソッドはBUG_REPORT_FILEの中身をStringで取得しています。
スタックトレースの他に、デバイス名やSDKのバージョンなど、
バグが発生した個体情報も追加しておきます。
特に、アプリのバージョン番号「sPackInfo.versionName」は重要なので追加しておいた方が良いです。
sPackInfo変数は以下のようにして取得しています。
public MyUncaughtExceptionHandler(Context context) { try { //パッケージ情報 sPackInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); } catch (NameNotFoundException e) { e.printStackTrace(); }
また、バグ報告の送信の有無に関わらず、BUG_REPORT_FILEを消しておきます。
このファイルの存在がトリガーになってバグ報告ダイアログが表示されるので、
一度表示した後はファイルを消しておきます。
private static void finish(DialogInterface dialog) { File file = BUG_REPORT_FILE; if (file.exists()) { file.delete(); } dialog.dismiss(); }
以上でhttp://foo.bar.org/bugにPOSTリクエストでバグ情報の送信が完了しました。
次にサーバ側です。
Google App Engine(GAE)を使って、バグ情報を格納し、開発者へメール連絡する仕組みをご紹介します。
データを格納してメールする程度の簡単な処理なので、
GAEは、記述量の少ないPythonで実装します。
データ構造は送信される内容のままです。
class BugData(db.Model): device = db.StringProperty()#device name model = db.StringProperty()#model name sdk = db.StringProperty()#sdk name version = db.StringProperty()#version number bug = db.TextProperty()#stacktrace create = db.DateTimeProperty(auto_now_add=True)
あとは、POSTリクエストを受けて、このデータベースに登録するだけです。
class BugReportHandler(webapp.RequestHandler): def get(self): self.get_or_post() def post(self): self.get_or_post() def get_or_post(self): dev = self.request.get("dev") mod = self.request.get("mod") sdk = self.request.get("sdk") ver = self.request.get("ver") bug = self.request.get("bug") #insert a new element db = BugData(device=dev, model=mod, sdk=sdk, version=ver, bug=bug)#row db.put()#save #report with email mail.send_mail(sender="dev@gmail.com", to="dev@gmail.com", subject="Bug Report", body=bug) self.response.out.write('Success!')
HTTPリクエストBODYから各要素(devやbugなど)を取り出し、
db.putメソッドでデータベースに登録しています。
スタックトレースの内容をmail.send_mailで開発者にメール送信しています。
これにより、開発者はブラウザでチェックしなくてもメールでバグの発生を知ることができます。
より利便性を高めるには、同じバグ報告をメールしないようにフィルタリング処理を追加するなどが考えられます。
以上が、AndroidアプリとGAEを使ったバグ報告システムでした。
バグの無いアプリが一番良いのですが、バグの無いプログラムは無いとも言われます。
バグとうまく付き合っていく方法として本システムはとても役立っていますので、
開発者の皆さんは参考にして頂き、ご自身のアプリを育てていって下さい。
去年(2009年)の忘年会から、このネタをBlogにアップして欲しいと言われ続けて、今頃ようやく公開しました。
遅くなってスミマセン。これからも、こういった開発コネタをアップし、開発者のサポートができればと思います。
また、デ部でも、code snippetなどのノウハウを溜める仕組みができたらイイなぁ…
小さなソースコードを溜めていけるWebシステム(CMS?)を構築できる技術者を絶賛募集中。手伝って下さい><
Androidアプリ部分のソースコードを公開しておきます。
自由にご利用下さい。
BugReport.zip
内容は下記の通りです。
package com.adamrocker.android.bugreport; import android.app.Activity; import android.os.Bundle; import android.view.View; import android.widget.Button; public class BugReportActivity extends Activity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Context context = getApplicationContext(); //修正 @2010/06/29 Thread.setDefaultUncaughtExceptionHandler(new MyUncaughtExceptionHandler(context));//修正 @2010/06/29 setContentView(R.layout.main); Button oobBtn = (Button)findViewById(R.id.oob_btn); oobBtn.setOnClickListener(new View.OnClickListener(){ public void onClick(View v) { int index = 5; String[] strs = new String[index]; String str = strs[index];//ここでIndexOutOfBoundsException }}); } public void onStart(){ super.onStart(); //前回バグで強制終了した場合はダイアログ表示 MyUncaughtExceptionHandler.showBugReportDialogIfExist(); } }
package com.adamrocker.android.bugreport; import java.io.BufferedReader; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.FileReader; import java.io.IOException; import java.io.PrintWriter; import java.lang.Thread.UncaughtExceptionHandler; import java.util.ArrayList; import java.util.List; import org.apache.http.NameValuePair; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.HttpPost; import org.apache.http.impl.client.DefaultHttpClient; import org.apache.http.message.BasicNameValuePair; import org.apache.http.protocol.HTTP; import android.app.AlertDialog; import android.content.Context; import android.content.DialogInterface; import android.content.DialogInterface.OnClickListener; import android.content.pm.PackageInfo; import android.content.pm.PackageManager.NameNotFoundException; import android.os.Build; import android.os.Environment; public class MyUncaughtExceptionHandler implements UncaughtExceptionHandler { private static File BUG_REPORT_FILE = null; static { String sdcard = Environment.getExternalStorageDirectory().getPath(); String path = sdcard + File.separator + "bug.txt"; BUG_REPORT_FILE = new File(path); } private static Context sContext; private static PackageInfo sPackInfo; private UncaughtExceptionHandler mDefaultUEH; public MyUncaughtExceptionHandler(Context context) { sContext = context; try { //パッケージ情報 sPackInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); } catch (NameNotFoundException e) { e.printStackTrace(); } mDefaultUEH = Thread.getDefaultUncaughtExceptionHandler(); } public void uncaughtException(Thread th, Throwable t) { try { saveState(t); } catch (FileNotFoundException e) { e.printStackTrace(); } mDefaultUEH.uncaughtException(th, t); } private void saveState(Throwable e) throws FileNotFoundException { StackTraceElement[] stacks = e.getStackTrace(); File file = BUG_REPORT_FILE; PrintWriter pw = null; pw = new PrintWriter(new FileOutputStream(file)); StringBuilder sb = new StringBuilder(); int len = stacks.length; for (int i = 0; i < len; i++) { StackTraceElement stack = stacks[i]; sb.setLength(0); sb.append(stack.getClassName()).append("#"); sb.append(stack.getMethodName()).append(":"); sb.append(stack.getLineNumber()); pw.println(sb.toString()); } pw.close(); } public static final void showBugReportDialogIfExist() { File file = BUG_REPORT_FILE; if (file != null & file.exists()) { AlertDialog.Builder builder = new AlertDialog.Builder(sContext); builder.setTitle("バグレポート"); builder.setMessage("バグ発生状況を開発者に送信しますか?"); builder.setNegativeButton("Cancel", new OnClickListener(){ public void onClick(DialogInterface dialog, int which) { finish(dialog); }}); builder.setPositiveButton("Post", new OnClickListener(){ public void onClick(DialogInterface dialog, int which) { postBugReportInBackground();//バグ報告 dialog.dismiss(); }}); AlertDialog dialog = builder.create(); dialog.show(); } } private static void postBugReportInBackground() { new Thread(new Runnable(){ public void run() { postBugReport(); File file = BUG_REPORT_FILE; if (file != null && file.exists()) [ file.delete(); } }}).start(); } private static void postBugReport() { List<NameValuePair> nvps = new ArrayList<NameValuePair>(); String bug = getFileBody(BUG_REPORT_FILE); nvps.add(new BasicNameValuePair("dev", Build.DEVICE)); nvps.add(new BasicNameValuePair("mod", Build.MODEL)); nvps.add(new BasicNameValuePair("sdk", Build.VERSION.SDK)); nvps.add(new BasicNameValuePair("ver", sPackInfo.versionName)); nvps.add(new BasicNameValuePair("bug", bug)); try { HttpPost httpPost = new HttpPost("http://foo.bar.org/bug"); httpPost.setEntity(new UrlEncodedFormEntity(nvps, HTTP.UTF_8)); DefaultHttpClient httpClient = new DefaultHttpClient(); httpClient.execute(httpPost); } catch (IOException e) { e.printStackTrace(); } } private static String getFileBody(File file) { StringBuilder sb = new StringBuilder(); try { BufferedReader br = new BufferedReader(new FileReader(file)); String line; while((line = br.readLine()) != null) { sb.append(line).append("\r\n"); } } catch (Exception e) { e.printStackTrace(); } return sb.toString(); } private static void finish(DialogInterface dialog) { File file = BUG_REPORT_FILE; if (file.exists()) { file.delete(); } dialog.dismiss(); } }
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.adamrocker.android.bugreport" android:versionCode="1" android:versionName="1.0"> <application android:icon="@drawable/icon" android:label="@string/app_name" android:debuggable="false"> <activity android:name=".BugReportActivity" android:label="@string/app_name"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> <uses-sdk android:minSdkVersion="3" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"></uses-permission> <uses-permission android:name="android.permission.INTERNET"></uses-permission> </manifest>
GAEのソースコードは下記の通りです。
application: bug-store version: 1 runtime: python api_version: 1 handlers: - url: /bug script: bugs.py
/bug/.*となっていたのを修正しました(at 15 Jan, 2011)
from google.appengine.ext import db, webapp from google.appengine.ext.webapp import util from google.appengine.api import mail class BugData(db.Model): device = db.StringProperty()#device name model = db.StringProperty()#model name sdk = db.StringProperty()#sdk name version = db.StringProperty()#version number bug = db.TextProperty()#stacktrace create = db.DateTimeProperty(auto_now_add=True) class BugReportHandler(webapp.RequestHandler): def get(self): self.get_or_post() def post(self): self.get_or_post() def get_or_post(self): dev = self.request.get("dev") mod = self.request.get("mod") sdk = self.request.get("sdk") ver = self.request.get("ver") bug = self.request.get("bug") #insert new element db = BugData(device=dev, model=mod, sdk=sdk, version=ver, bug=bug) db.put() #report with email mail.send_mail(sender="developer@gmail.com", to="developer@gmail.com", subject="Bug Report", body=bug) self.response.out.write('Success!') def main(): application = webapp.WSGIApplication([('/bug', BugReportHandler)], debug=False) util.run_wsgi_app(application) if __name__ == '__main__': main()
- Newer: ActivityのOpenとCloseをアニメーションさせる
- Older: Android1.5でも動くSimeji
Comments:6
- benishouga 10-02-16 (火) 1:14
-
本記事を参考に開発者皆で使えるバグのPOST先を作ってみました。
まだ試験運用ですが、アグレッシブな方おりましたら、是非ご利用くださいませ。
⇒ http://aexceptions.appspot.com/そして改めて、良記事を書いてくださった
adamrockerさんに感謝です! - Yutaka.Nakadouzono 10-05-06 (木) 0:16
-
すばらしい情報です。
この情報のおかげでほとんどのソフト開発効率が抜群に
伸びると思います。すばらしい貢献です。
大変ありがとうございます。 - 通りすがり 10-08-06 (金) 17:24
-
「バグレポート google app engine」でぐぐってたらここが一番先頭に出ました。(本当は、bugzillaを間借りさせてくれる
ところがないかなあと思ったんです。。)たまたま、私もAndroidの開発をしていますが、これはぜひ
導入したいと思います。ありがとうございました。 - とおりすがり 10-08-11 (水) 22:54
-
Context context = getApplicationContext();
Thread.setDefaultUncaughtExceptionHandler(new MyUncaughtExceptionHandler(context));上のコードですが、以下のURLのようなエラーが出ています。
thisを渡したほうが良いのではないかと思います。http://tech.shantanugoel.com/2010/07/08/badtokenexception-android-dialog-getapplicationcontext.html
- sarapapa 10-09-04 (土) 20:48
-
大変有用な情報ありがとうございます。
例外キャッチとGAE連携するあたり、ぜひ活用したいと思います。ところで、saveState()メソッドですが、以下のようにシンプルにできそうです。
private void saveState(Throwable e) throws FileNotFoundException {
File file = BUG_REPORT_FILE;
PrintWriter pw = null;
pw = new PrintWriter(new FileOutputStream(file));
e.printStackTrace(pw);
pw.close();
}あと、showBugReportDialogIfExist()メソッドですが、上の投稿の現象が発生したため、引数にActivityを渡すように変更したらうまくいきました。
public static final void showBugReportDialogIfExist(Context context) {
File file = BUG_REPORT_FILE;
if (file != null & file.exists()) {
AlertDialog.Builder builder = new AlertDialog.Builder(context); - ボビン 11-02-02 (水) 10:41
-
有益な情報ありがとうございます。
ソースのzipを解凍し、10/06/29の修正分を反映させて実装してみました。
結果、ハンドラーにてエラーを捕らえることができましたが、その後アプリの応答がなくなります。スマートフォンの右端の戻るボタンも受付ません。制御を戻すには何かが抜けているのでしょうか?同じような現象は出ませんか?
Trackbacks:6
- Trackback URL for this entry
- http://www.adamrocker.com/blog/288/bug-report-system-for-android.html/trackback/
- Listed below are links to weblogs that reference
- Androidアプリのバグ報告システムを考える from throw Life
- pingback from Twitter Trackbacks for throw Life - Androidアプリのバグ報告システムを考える [adamrocker.com] on Topsy.com 10-01-23 (土) 10:57
-
[…] Topsy Retweet Button var topsy_style = “small”; var topsy_order = “count,retweet,badge”; var topsy_url = “http://www.adamrocker.com/blog/288/bug-report-system-for-android.html”; Add Topsy Retweet Button to your Blog or Web Site. WordPress Web Sites […]
- pingback from links for 2010-01-24 « 個人的な雑記 10-01-25 (月) 7:03
-
[…] throw Life – Androidアプリのバグ報告システムを考える (tags: android) […]
- pingback from Android端末のシェアやはりXperia強し!がんばれ « BPS株式会社 開発ブログ Beyond Perspective Solutions LTD. 10-07-14 (水) 21:52
-
[…] バグ報告システムとは何ぞやという方はこちらの記事をどうぞ。 […]
- pingback from ACRAを導入しました | DailyTimer.net Blog 11-02-27 (日) 6:30
-
[…] throw Life – Androidアプリのバグ報告システムを考える […]
- pingback from アプリ異常終了時のエラーを開発者に送信する | GE Android Blog 11-04-16 (土) 11:04
-
[…] アプリの強制終了が発生した際にそのときのエラー情報を送信する処理を考えます。 throw Life – Androidアプリのバグ報告システムを考えるが元ネタです。 ここで確認しているのは次の点です。 ・catchされない例外を捕捉する ・捕捉した例外を処理する ・例外情報をテキストファイルとしてSDカードに出力する ・次回起動時にダイアログで不具合情報の送信確認を行う ・GAEで不具合情報をメールで通知する ●catchされない例外を捕捉する まずはActivityにcatchされない例外の対応を記述します。 onCreateメソッドなどの早い段階で Thread.UncaughtExceptionHandlerを設定してやります。 […]
- pingback from Android端末のシェアやはりXperia強し!がんばれ | TechRacho 11-05-19 (木) 15:47
-
[…] バグ報告システムとは何ぞやという方はこちらの記事をどうぞ。 […]

