前言
クラッシュレポートなどの回収はFirebase Crashlyticsの利用はおすすめですが、この記事ではFirebase Crashlyticsを利用しなくて、自分のサーバーへ送信したいときの実装案について述べます。
例外キャッチ
- FlutterError.onError
主にUIの構築エラーをキャッチできる - runZonedGuarded
null pointerエラー、リストのインデックス超過エラーとかtry catchしていないエラーはrunZonedGuardedでキャッチできる
void main() async {
await runZonedGuarded(() async {
WidgetsFlutterBinding.ensureInitialized();
//Flutterでキャッチされた例外/エラー
FlutterError.onError = (errorDetails) {
logger.e(errorDetails);
};
runApp();
}, (error, stackTrace) async {
//Flutterでキャッチされなかった例外/エラー
logger.e(error);
});
}
ログ送信
エラーキャッチできたため、アプリの強制終了がなく、WebApi経由でエラーログを送信することが可能です。
final String apiUrl = 'YOUR_API_URL';
try {
await http.post(
Uri.parse(apiUrl),
headers: {'Content-Type': 'application/json'},
body: '{"error": "$errorMessage"}',
);
} catch (e) {
print('送信失敗: $e');
}
logger
上記のSDKを継承することで、ログを出力処理すると共にログの送信処理を行います。
class RemoteLogOutput extends LogOutput {
@override
void output(OutputEvent event) async {
try {
final logMessage = event.lines.join('\n');
print(logMessage);
// 省略
// Web Apiでログ送信処理
} catch (e) {
print('Failed to send log to remote server: $e');
}
}
}
キャッチできなかった例外
キャッチできなかった例外またはクラッシュ発生した時、ログ送信処理終了する前、アプリが強制終了される場合、クラッシュレポートまたはエラーログをローカルに保存し、次回アプリを開くとき、ログ送信処理を行います。
Flutterでクラッシュキャッチできないため、iOSとAndroidそれぞれの実装となります。
- iOS
ios/Runner/AppDelegate.swift
上記ファイルに下記のソースを追加する
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
NSSetUncaughtExceptionHandler { exception in
// ログを保存する
AppDelegate.writeCrashLogToFile(exception: exception)
}
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// クラッシュ発生時ログファイルをローカルに保存する
static func writeCrashLogToFile(exception: NSException) {
// UIApplication.shared.delegate のAppDelegateインスタンスを取得
guard let self = UIApplication.shared.delegate as? AppDelegate else { return }
let fileManager = FileManager.default
let urls = fileManager.urls(for: .documentDirectory, in: .userDomainMask)
if let crashLogFile = urls.first?.appendingPathComponent("crash_log.txt") {
let crashLog = """
Name: \(exception.name.rawValue)
Reason: \(String(describing: exception.reason))
UserInfo: \(exception.userInfo)
StackTrace: \(exception.callStackSymbols.joined(separator: "\n"))
"""
do {
if fileManager.fileExists(atPath: crashLogFile.path) {
let fileHandle = try FileHandle(forWritingTo: crashLogFile)
fileHandle.seekToEndOfFile()
if let crashLogData = crashLog.data(using: .utf8) {
fileHandle.write(crashLogData)
}
fileHandle.closeFile()
} else {
try crashLog.write(to: crashLogFile, atomically: true, encoding: .utf8)
}
} catch {
print("Error writing crash log to file: \(error)")
}
}
}
}
- Android
- android/app/src/main/AndroidManifest.xml
上記ファイルにストレージの保存と読み取り権限を追加します。
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
- android/app/src/main/kotlin/YOU_APP_ID/CrashLogUtils.kt
上記のファイルを作成する
package YOU_APP_ID // 自分のAPPID com.xxx.xxx import android.content.Context import android.util.Log import java.io.File import java.io.FileOutputStream import java.io.PrintWriter import java.io.StringWriter object CrashLogUtils { private var isCrashLogSaved = false fun saveCrashLog(context: Context, crashLog: String) { if (isCrashLogSaved) { return } var outputStream: FileOutputStream? = null try { val crashLogDir = getCrashLogDirectory(context) if (crashLogDir != null) { val crashLogFile = File(crashLogDir, "crash_log.txt") outputStream = FileOutputStream(crashLogFile, true) outputStream.write(crashLog.toByteArray()) Log.d("CrashLogUtils", "Crash log saved: " + crashLogFile.absolutePath) isCrashLogSaved = true } else { Log.e("CrashLogUtils", "Failed to create crash log directory") } } catch (e: Exception) { e.printStackTrace() Log.e("CrashLogUtils", "Error saving crash log: " + e.message) } finally { outputStream?.close() } } private fun getCrashLogDirectory(context: Context): File? { val directory = File(context.getExternalFilesDir(null), "crash_logs") if (!directory.exists()) { if (!directory.mkdirs()) { return null } } return directory } }
- android/app/src/main/kotlin/YOU_APP_ID/MainActivity.kt
package YOU_APP_ID // 自分のAPPID com.xxx.xxx import android.content.Context import android.os.Bundle import androidx.annotation.NonNull import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugins.GeneratedPluginRegistrant import java.io.PrintWriter import java.io.StringWriter class MainActivity : FlutterActivity() { override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { GeneratedPluginRegistrant.registerWith(flutterEngine) Thread.setDefaultUncaughtExceptionHandler(CustomUncaughtExceptionHandler(this)) } private class CustomUncaughtExceptionHandler(private val context: Context) : Thread.UncaughtExceptionHandler { override fun uncaughtException(thread: Thread, throwable: Throwable) { val crashLog = getStackTraceAsString(throwable) CrashLogUtils.saveCrashLog(context, crashLog) } private fun getStackTraceAsString(throwable: Throwable): String { val sw = StringWriter() val pw = PrintWriter(sw) throwable.printStackTrace(pw) return sw.toString() } } }
- android/app/src/main/AndroidManifest.xml
ストレージから読み取り
クラッシュログの保存と違って、読み取りはFlutterで実現できます。path_provider
を使います。
/// ドキュメント保存ディレクトリ
static Future<String> getFilePath() async {
final directory = await getApplicationDocumentsDirectory();
return directory.path;
}
/// 保存したファイルからテキストを取得する
static Future<String> readFromFile(String fileName) async {
final path = await getCrashLogFilePath();
final file = File('$path/$fileName.txt');
if (await file.exists()) {
return await file.readAsString();
}
return '';
}
/// ファイル削除
static Future<void> deleteFile(String fileName) async {
final path = await getCrashLogFilePath();
final file = File('$path/$fileName.txt');
if (await file.exists()) {
await file.delete();
}
}
/// ファイル保存ディレクトリ
static Future<String> getCrashLogFilePath() async {
if (Platform.isIOS) {
final path = await getFilePath();
return path;
}
final directory = await getExternalStorageDirectory();
final crashLogFilePath = '${directory?.path}/crash_logs';
return crashLogFilePath;
}