LoginSignup
7
2

Flutter の MethodChannel で例外発生時に PlatformException に入る値の詳細を調査しました

Last updated at Posted at 2022-06-21

概要

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つがあります。

このうち後者のencodeErrorEnvelopeメソッドによって結果がエンコードされると、それを受け取ったDart層では PlatformExceptionが発生する仕組みになっています。

Androidの結果返却の実装

Androidの実装を追いかけてみました。

error envelopeを書き込む実装

Androidにも StandartMethodCodec というクラスが用意されていて、 encodeErrorEnvelopeメソッドが存在します。StandardMethodCodec.javaの71行目あたりです。

一方、encodeErrorEnvelopeWithStackTrace というものも用意されています。StandardMethodCodec.javaの89行目あたりです。

image.png

両者の違いは1箇所だけです。前者は errorCodeerrorMessageerrorDetail だけエンコードしていますが、後者はその後に errorStackTrace をエンコードしています。

なお、ちょっと興味深いですが、errorDetailとして Throwableのオブジェクトが渡された場合は、その Throwableオブジェクトが持っているスタックトレース情報 (String) を errorDetailとしてセットしています。

上記 encodeErrorEnvelopeの呼び出し箇所

実際に上記メソッドを呼んでいるのは、Native層の onMethodCall() を呼びだしている このあたりです。

image.png

僕らが通常 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();
    }
  }

ここで、呼び出されたメソッドの名前によって処理を分岐し、結果は resultsuccess()error()notImplemented() を呼び出すことで返却しています。

このうち error() を呼び出すと、さきほどのこのコードに処理が渡ってきます。

              @Override
              public void error(String errorCode, String errorMessage, Object errorDetails) {
                reply.reply(codec.encodeErrorEnvelope(errorCode, errorMessage, errorDetails));
              }

ここで呼び出しているのは withStackTrace() の方ではないので、 errorCodeerrorMessageerrorDetails にしか値は入らず、 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だけでよかったのでは……という気もします。

なおこの時の、errorCodeerrorMessage は以下のようになっています。

  • 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 みたいなものは見当たりません。上記メソッドも、errorCodeerrorMessageerrorDetail だけエンコードしていて、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を返す
  • resultに FlutterErrorクラスのインスタンスがセットされていたらエラー
    • Dart層で PluginExceptionが発生
  • それ以外の場合は成功

となっています。実装側が明示的に FlutterErrorを resultに渡した場合だけ PluginExceptionが発生します。

なお、iOS (Objective-C) にも Javaのようなすっぽ抜ける例外はあるにはあるのですが、通常は使われませんので、ここでもノーケアです。もしすっぽ抜けたらアプリが落ちるんじゃないかな?と思います。

errorCode、 errorMessage, errorDetailsの値

iOSの場合、PlatformExceptionの stackTraceは常に nullであることが分かりましたが、残る errorCode、 errorMessage、 errorDetailsの値がどうなるかみてみましょう。

こちらは、先ほどの encodeErrorEnvelopeメソッドの実装を見れば分かりますが、FlutterErrorの codemessagedetails がセットされます。

使う側のサンプルを見てみましょう。

- (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をまとめてハンドリングするエラーハンドラーが作りづらく、呼び出したメソッドごとに作り分ける必要が出てしまうため)

7
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2