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

# 概要

Flutterでネイティブ層のAPIを呼び出した際、ネイティブ側でエラーが起こると、Dart層では [PlatformException](https://api.flutter.dev/flutter/services/PlatformException-class.html) がスローされます

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クラスの説明](https://api.flutter.dev/flutter/services/MethodChannel-class.html)に詳しく書かれていますが、 Dart層と Native層の間は非同期の通信として実装されていて、その間をバイナリデータでやりとりしています。

* Dart → Native の呼び出し
    * Dart側から渡したい引数や呼び出したいメソッドの情報などがバイナリフォーマットになって渡される
* Dart ← Native の結果渡し
    * Native層の結果もやはりバイナリフォーマットとしてエンコードされ、Dart層に戻されます

このバイナリ通信のコーデックは [MethodCodecクラス](https://api.flutter.dev/flutter/services/MethodCodec-class.html)が担っています。このクラスは抽象クラスで、実装には StandardMethodCodecクラスや、JSONMethodCodecクラスがあります。おそらく通常は StandardMethodCodecクラスが使われているものと思われます。

この仕組みが、Dart層、Java層(Android)、ObjectiveC層(iOS)など、それぞれで実装されています。

## 成功レスポンスとエラーレスポンス

MethodCodecクラスには、Dart→Nativeのリクエストをエンコードする機能と、Native→Dartの結果をエンコードする機能の両方があります。結果を返すためのメソッドとして以下の2つがあります。

* [encodeSuccessEnvelopeメソッド](https://api.flutter.dev/flutter/services/MethodCodec/encodeSuccessEnvelope.html)
    * 成功結果をエンコードします
* [encodeErrorEnvelopeメソッド](https://api.flutter.dev/flutter/services/MethodCodec/encodeErrorEnvelope.html)
    * エラー結果をエンコードします

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


# Androidの結果返却の実装

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

## error envelopeを書き込む実装
Androidにも `StandartMethodCodec` というクラスが用意されていて、 `encodeErrorEnvelope`メソッドが存在します。[StandardMethodCodec.javaの71行目あたり](https://github.com/flutter/engine/blob/b0c129db124d19e4c55d242701e5792be9d304a8/shell/platform/android/io/flutter/plugin/common/StandardMethodCodec.java#L71)です。

一方、`encodeErrorEnvelopeWithStackTrace` というものも用意されています。[StandardMethodCodec.javaの89行目あたり](https://github.com/flutter/engine/blob/b0c129db124d19e4c55d242701e5792be9d304a8/shell/platform/android/io/flutter/plugin/common/StandardMethodCodec.java#L89)です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41616/f5db5c8e-8883-89b9-0fad-710c68f51a93.png)

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

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

## 上記 encodeErrorEnvelopeの呼び出し箇所

実際に上記メソッドを呼んでいるのは、Native層の `onMethodCall()` を呼びだしている [このあたり](https://github.com/flutter/engine/blob/b0c129db124d19e4c55d242701e5792be9d304a8/shell/platform/android/io/flutter/plugin/common/MethodChannel.java#L259)です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41616/43b32dc2-9d60-14d4-5cdf-38eafb3a1af6.png)

僕らが通常 Native層を実装する場合は、ここで呼び出されている `onMethodCall()` の中身を実装します。詳しい使い方は[ここ](https://api.flutter.dev/flutter/services/MethodChannel/invokeMethod.html)にありますが、少し引用すると、こんな感じです。

``` Java
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()` を呼び出すと、さきほどのこのコードに処理が渡ってきます。

``` Java
              @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](https://api.flutter.dev/flutter/services/MissingPluginException-class.html) が送出されるようです。

## Androidで PlatformExceptionの stackTraceが非nullになるケース

PlatformExceptionの stackTraceに何か値がセットされるのは、 `encodeErrorEnvelopeWithStackTrace` が呼び出された場合だけです。これは、

``` Java
      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`メソッドが存在します。[このあたり](https://github.com/flutter/engine/blob/2962099077b36704071802ea5595f4a016a1c214/shell/platform/darwin/common/framework/Source/FlutterStandardCodec.mm#L106)です。

``` Objective-C
- (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` クラスのこの辺りです。

https://github.com/flutter/engine/blob/bf4eba332842ac17f11c9c2e5deaca42495d3acf/shell/platform/darwin/common/framework/Source/FlutterChannels.mm#L294

``` Objective-C
  // 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](https://api.flutter.dev/flutter/services/MissingPluginException-class.html)が発生
* 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` がセットされます。

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

``` Objective-C
- (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オブジェクトに値を詰めて返すことを想定しています。

``` Objective-C
    } else {
      result([FlutterError errorWithCode:[NSString stringWithFormat:@"Error %ld", error.code]
                                 message:error.domain
                                 details:error.localizedDescription]);
```

Swiftの場合も似たような感じになります。
``` 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の関係については、少し古いですが私の記事もご参照ください。

https://qiita.com/kunichiko/items/e5405b9edbea8fed86d2 


# まとめ

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