概要
Flutterでネイティブ層のAPIを呼び出した際、ネイティブ側でエラーが起こると、Dart層では PlatformException がスローされます
PlatformExceptionクラスには以下のプロパティがありますが、iOS/Androidで実際に何の値が入るのかを、ソースコードレベルで追いかけて調べてみました。
- code → String
- An error code.
- details → dynamic
- Error details, possibly null.
- message → String?
- A human-readable error message, possibly null.
- stacktrace → String?
- Native stacktrace for the error, possibly null.
特に、messageや stacktraceは nullableなので、どういう時に値が入り、どういう時に入らないのかが気になって調べています。
MethodChannnelの仕組み
MethodChannelクラスの説明に詳しく書かれていますが、 Dart層と Native層の間は非同期の通信として実装されていて、その間をバイナリデータでやりとりしています。
- Dart → Native の呼び出し
- Dart側から渡したい引数や呼び出したいメソッドの情報などがバイナリフォーマットになって渡される
- Dart ← Native の結果渡し
- Native層の結果もやはりバイナリフォーマットとしてエンコードされ、Dart層に戻されます
このバイナリ通信のコーデックは MethodCodecクラスが担っています。このクラスは抽象クラスで、実装には StandardMethodCodecクラスや、JSONMethodCodecクラスがあります。おそらく通常は StandardMethodCodecクラスが使われているものと思われます。
この仕組みが、Dart層、Java層(Android)、ObjectiveC層(iOS)など、それぞれで実装されています。
成功レスポンスとエラーレスポンス
MethodCodecクラスには、Dart→Nativeのリクエストをエンコードする機能と、Native→Dartの結果をエンコードする機能の両方があります。結果を返すためのメソッドとして以下の2つがあります。
-
encodeSuccessEnvelopeメソッド
- 成功結果をエンコードします
-
encodeErrorEnvelopeメソッド
- エラー結果をエンコードします
このうち後者のencodeErrorEnvelope
メソッドによって結果がエンコードされると、それを受け取ったDart層では PlatformExceptionが発生する仕組みになっています。
Androidの結果返却の実装
Androidの実装を追いかけてみました。
error envelopeを書き込む実装
Androidにも StandartMethodCodec
というクラスが用意されていて、 encodeErrorEnvelope
メソッドが存在します。StandardMethodCodec.javaの71行目あたりです。
一方、encodeErrorEnvelopeWithStackTrace
というものも用意されています。StandardMethodCodec.javaの89行目あたりです。
両者の違いは1箇所だけです。前者は errorCode
、 errorMessage
、 errorDetail
だけエンコードしていますが、後者はその後に errorStackTrace
をエンコードしています。
なお、ちょっと興味深いですが、errorDetailとして Throwableのオブジェクトが渡された場合は、その Throwableオブジェクトが持っているスタックトレース情報 (String) を errorDetailとしてセットしています。
上記 encodeErrorEnvelopeの呼び出し箇所
実際に上記メソッドを呼んでいるのは、Native層の onMethodCall()
を呼びだしている このあたりです。
僕らが通常 Native層を実装する場合は、ここで呼び出されている onMethodCall()
の中身を実装します。詳しい使い方はここにありますが、少し引用すると、こんな感じです。
public class MusicPlugin implements MethodCallHandler {
@Override
public void onMethodCall(MethodCall call, Result result) {
switch (call.method) {
case "isLicensed":
result.success(MusicApi.checkLicense());
break;
case "getSongs":
final List<MusicApi.Track> tracks = MusicApi.getTracks();
final List<Object> json = ArrayList<>(tracks.size());
for (MusicApi.Track track : tracks) {
json.add(track.toJson()); // Map<String, Object> entries
}
result.success(json);
break;
case "play":
final String song = call.argument("song");
final double volume = call.argument("volume");
try {
MusicApi.playSongAtVolume(song, volume);
result.success(null);
} catch (MusicalException e) {
result.error("playError", e.getMessage(), null);
}
break;
default:
result.notImplemented();
}
}
ここで、呼び出されたメソッドの名前によって処理を分岐し、結果は result
の success()
、 error()
、 notImplemented()
を呼び出すことで返却しています。
このうち error()
を呼び出すと、さきほどのこのコードに処理が渡ってきます。
@Override
public void error(String errorCode, String errorMessage, Object errorDetails) {
reply.reply(codec.encodeErrorEnvelope(errorCode, errorMessage, errorDetails));
}
ここで呼び出しているのは withStackTrace()
の方ではないので、 errorCode
、errorMessage
、errorDetails
にしか値は入らず、 PlatformExceptionの stackTrace は nullになります。
なお、errorDetailsに Throwableを渡せばStackTrace文字列がエンコードされるのをさっき見ましたが、あくまで PlatformException の errorDetailsプロパティの文字列として渡されるだけ なので注意が必要です。
notImplemented()
ちなみに、result.notImplemented()
とした場合、Dart層では MissingPluginException が送出されるようです。
Androidで PlatformExceptionの stackTraceが非nullになるケース
PlatformExceptionの stackTraceに何か値がセットされるのは、 encodeErrorEnvelopeWithStackTrace
が呼び出された場合だけです。これは、
try {
handler.onMethodCall(
call,
new Result() {
// :省略:
});
} catch (RuntimeException e) {
Log.e(TAG + name, "Failed to handle method call", e);
reply.reply(
codec.encodeErrorEnvelopeWithStacktrace(
"error", e.getMessage(), null, getStackTrace(e)));
}
の部分です。 つまり、僕らが実装した onMethodCall()
の実装の内部で例外がすっぽ抜けてしまい、 resultに対して何もメソッドを呼ばずに終了してしまった場合だけ、stackTraceプロパティに値がセットされます。 逆にその場合は errorDetailsに nullがセットされるので、両方に値がセットされるケースはないということがわかります。
整理すると以下のようになります。
- 自前でエラーハンドリングして、
result.error()
の errorDetailsに Throwableを渡した場合- PlatformExceptionの errorDetailsにスタックトレース文字列が入る
- PlatformExceptionの stackTraceは null
- 自前でエラーハンドリングせずに、例外がすっぽ抜けた場合
- PlatformExceptionの errorDetailsは null
- PlatformExceptionの stackTraceにスタックトレース文字列が入る
つまり、自前でハンドリングしつつ、 stackTrace
にスタックトレースをセットする方法はないということです。うーん、どうしてこういう仕様にしたんですかね? errorDetailsだけでよかったのでは……という気もします。
なおこの時の、errorCode
と errorMessage
は以下のようになっています。
- errorCode
- "error" という文字列
- errorMessage
- すっぽ抜けた例外の
e.getMessage()
の結果
- すっぽ抜けた例外の
iOSの結果返却の実装
同じように、iOSの実装も追いかけてみました。
error envelopeを書き込む実装
iOSにも AndroidのStandartMethodCodec
クラスに相当する FlutterStandardCodec
クラスが用意されていて、 encodeErrorEnvelope
メソッドが存在します。このあたりです。
- (NSData*)encodeErrorEnvelope:(FlutterError*)error {
NSMutableData* data = [NSMutableData dataWithCapacity:32];
FlutterStandardWriter* writer = [_readerWriter writerWithData:data];
[writer writeByte:1];
[writer writeValue:error.code];
[writer writeValue:error.message];
[writer writeValue:error.details];
return data;
}
こちらには、Androidにあった encodeErrorEnvelopeWithStackTrace
みたいなものは見当たりません。上記メソッドも、errorCode
、 errorMessage
、 errorDetail
だけエンコードしていて、stackTrace
はエンコードしていません。
つまり、iOS版の場合、stackTraceを返却する仕組みは存在しない ということになります。
上記 encodeErrorEnvelopeの呼び出し箇所
せっかくなのでもう少し追いかけてみましょう。上記メソッドを呼び出しているのは、 FlutterChannels
クラスのこの辺りです。
// Make sure the block captures the codec, not self.
NSObject<FlutterMethodCodec>* codec = _codec;
FlutterBinaryMessageHandler messageHandler = ^(NSData* message, FlutterBinaryReply callback) {
FlutterMethodCall* call = [codec decodeMethodCall:message];
handler(call, ^(id result) {
if (result == FlutterMethodNotImplemented) {
callback(nil);
} else if ([result isKindOfClass:[FlutterError class]]) {
callback([codec encodeErrorEnvelope:(FlutterError*)result]);
} else {
callback([codec encodeSuccessEnvelope:result]);
}
});
};
Android版ととてもよく似ていますね。僕らが実装した Native側の実装がコールバックを呼び出すことで呼び出し元のDart層に結果を返すようになっています。
- resultに FlutterMethodNotImplemented がセットされていたら nilを返す
- Dart層で MissingPluginExceptionが発生
- resultに FlutterErrorクラスのインスタンスがセットされていたらエラー
- Dart層で PluginExceptionが発生
- それ以外の場合は成功
となっています。実装側が明示的に FlutterErrorを resultに渡した場合だけ PluginExceptionが発生します。
なお、iOS (Objective-C) にも Javaのようなすっぽ抜ける例外はあるにはあるのですが、通常は使われませんので、ここでもノーケアです。もしすっぽ抜けたらアプリが落ちるんじゃないかな?と思います。
errorCode、 errorMessage, errorDetailsの値
iOSの場合、PlatformExceptionの stackTraceは常に nullであることが分かりましたが、残る errorCode、 errorMessage、 errorDetailsの値がどうなるかみてみましょう。
こちらは、先ほどの encodeErrorEnvelopeメソッドの実装を見れば分かりますが、FlutterErrorの code
、 message
、details
がセットされます。
使う側のサンプルを見てみましょう。
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
if ([@"isLicensed" isEqualToString:call.method]) {
result([NSNumber numberWithBool:[BWPlayApi isLicensed]]);
} else if ([@"getSongs" isEqualToString:call.method]) {
NSArray* items = [BWPlayApi items];
NSMutableArray* json = [NSMutableArray arrayWithCapacity:items.count];
for (final BWPlayItem* item in items) {
[json addObject:@{ @"id":item.itemId, @"title":item.name, @"artist":item.artist }];
}
result(json);
} else if ([@"play" isEqualToString:call.method]) {
NSString* itemId = call.arguments[@"song"];
NSNumber* volume = call.arguments[@"volume"];
NSError* error = nil;
BOOL success = [BWPlayApi playItem:itemId volume:volume.doubleValue error:&error];
if (success) {
result(nil);
} else {
result([FlutterError errorWithCode:[NSString stringWithFormat:@"Error %ld", error.code]
message:error.domain
details:error.localizedDescription]);
}
} else {
result(FlutterMethodNotImplemented);
}
}
先ほどの Javaの例の Objective-C版です。Objective-Cでは例外を Throwする代わりに、 NSErrorのハンドラ(ポインタのポインタ)にNSErrorのインスタンスをセットしてエラーを返す仕組みになっています。Swiftは見た目上例外をスローするような書き方ができますが、内部の実装はObjective-Cの実装と似たような感じになっています。
ともかく、エラーが起きたら、以下のようにして FlutterErrorオブジェクトに値を詰めて返すことを想定しています。
} else {
result([FlutterError errorWithCode:[NSString stringWithFormat:@"Error %ld", error.code]
message:error.domain
details:error.localizedDescription]);
Swiftの場合も似たような感じになります。
do {
let rusult = try hogehoge()
result(result)
} catch e as NSError {
result(FlutterError(code: "Error \(e.code)"
message:e.domain
details:e.localizedDescription))
}
ちなみに Objective-Cの NSErrorと Swiftの Errorの関係については、少し古いですが私の記事もご参照ください。
まとめ
まとめると、PlatformException の各プロパティに入る値は、以下のようになります。
code | message | details | stackTrace | |
---|---|---|---|---|
Android(自分でエラーハンドリング) | 自由文字列 | 自由文字列(e.getMessage()を想定?) | dyanmic (Throwableを渡した場合はスタックトレース文字列) | null |
Android(例外すっぽ抜け) | "error" | e.getMessage() | null | スタックトレース文字列 |
iOS(自分でエラーハンドリング) | 自由文字列("Error %ld" にNSErrorのcodeをセットすることを想定?) | 自由文字列(e.domainを想定?) | dynamic (e.localizedDescriptionを想定?) | null |
iOS(例外すっぽ抜け) | - | - | - | - |
うーん、何となく全体的に統一感がない感じがしちゃいますね😅
使う側である程度コントロールできるようにはなっているものの、プラグインごとにルールが揺れてしまいそうですし、なにより、Andaroidで例外がすっぽ抜けた時に code に "error"
が入るとか、かなりやっつけ仕事な感じがしてしまいます。
たとえば NSErrorにある domainみたいにプラグインごとに被らなそうなパッケージ名を入れるプロパティを作っておいて、最悪それで switchできるようにしておくとか、そういう工夫もあったんじゃないか?という気がします。(そういうのがないと PlatformExceptionをまとめてハンドリングするエラーハンドラーが作りづらく、呼び出したメソッドごとに作り分ける必要が出てしまうため)