2012年11月5日月曜日

Androidアプリのバグ報告システムを考える

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

動作概要
catchしなくてよい例外
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されずにアプリが強制終了します。

どこでこの例外が発生したのかが分かると、多くの問題は解決可能です。
これを捕捉するシステムを考えるのが本エントリの目的です。

catchしない例外を補足する
Javaにはcatchしなかった例外を補足するThread.UncaughtExceptionHandlerという仕組みが元々備わっています。
今回はコレを利用します。

public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Context context = getApplicationContext();
Thread.setDefaultUncaughtExceptionHandler(new MyUncaughtExceptionHandler(context));
}
*アプリにつき1回だけハンドラを登録するだけでOKです。スレッドは関係ありません。
MyUncaughtExceptionHandlerの引数にActivityを渡すとメモリリークする可能性があります。
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で使っている具体的なシステムをご紹介します。
あくまで一例なので、他に良い方法があるかもしれません。

Simejiのバグ報告システム
設計
catchしていない例外が発生した場合、そのスタックトレースを外部記憶装置(SDカード等)に保存
アプリ起動時に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カード内のファイル操作をマルチスレッドを無視した実装になっていましたので修正しました。
ユーザの許可無くサーバに情報を送信するのは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でバグレポート
データを格納してメールする程度の簡単な処理なので、
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ソースコード
Androidアプリ部分のソースコードを公開しておきます。
自由にご利用下さい。
BugReport.zip

内容は下記の通りです。

BugReportActivity.java
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();
Thread.setDefaultUncaughtExceptionHandler(new MyUncaughtExceptionHandler(context));
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();
}
}
MyUncaughtExceptionHandler.java
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();
}
}
AndroidManifest.xml
<?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ソースコード
GAEのソースコードは下記の通りです。

app.yaml
application: bug-store
version: 1
runtime: python
api_version: 1

handlers:
- url: /bug
script: bugs.py


bugs.py
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()

0 件のコメント:

コメントを投稿