Cosnomi

プログラミングをする医学生。物を作ること、自動化すること、今まで知らなかったことを知ることが好き。TypeScript書いたり、Pythonで機械学習したりなど。

Twitter / GitHub / GPG key / Fediverse / My Page
TOP >

Flutterのreleaseビルドだけで変数名がおかしくなるのは難読化処理が原因

Flutterでネイティブの機能をwrapするパッケージを使っていた際、releaseビルドのときだけそのパッケージの関数から返ってくる変数の名前がa, b, cのような文字に置換され、目的の値がnullとして取得される現象に遭遇しました。 原因は、releaseビルドでのみ設定される難読化処理Proguardが変数名を置換していたためでした。問題となっていたパッケージについてのみ、Proguardを無効化することでこの問題を解決できました。

現象

現在私が作っているアプリでは、通話の待ち受け画面を表示するために flutter-callkit-incomingというパッケージを使っています。通話の待ち受け画面は通常、iOSとAndroidでそれぞれ異なる処理が必要で、パラメータも異なります。 しかし、このパッケージはそれをwrapしてくれるので、プラットフォーム別の処理は不要になります。 一般的に、このようにネイティブのwrapperとして機能するパッケージは他にも色々あるかと思います。

私の場合は、次のように使用していました。

final activeCall = (await FlutterCallkitIncoming.activeCalls()).first;

// debugビルドでは正しく名前が表示されるが、releaseビルドではnullになってしまう
print(activeCall['nameCaller']);

// debugビルド: {'id': '123456', 'nameCaller': 'Jack', 'appName': 'CallKit', ....}
// releaseビルド: {'a': '123456', 'b': 'Jack', 'c': 'CallKit', .....}
print(activeCall);

debugビルドでは特に問題が生じていませんでしたが、releaseビルドを行った際、activeCall['appName'] などで取得していた値がnullになっていることから、activeCallのkey名が難読化されていることに気づきました。

解決

問題を引き起こしているパッケージについて、Proguardによる難読化処理を適用しないように例外の設定をします。具体的には、proguard-rules.pro というファイルに次のように書き込みます。

-keep class com.hiennv.flutter_callkit_incoming.** { *; }

この設定は問題となっているパッケージやクラスによって異なることに気をつけてください。 パッケージによっては、README.mdなどで追加すべき設定が公開されていることがあります。 特に、com.hiennv.flutter_callkit_incoming. の部分はそのパッケージ名に変更することが必要です。 この設定例では、そのパッケージのすべてを対象としていますが、特定のクラスのみに絞ることも可能です。 その場合、最適化と難読化のメリットをさらに享受できると思われます。

考察

ProguardとはAndroidアプリの最適化、難読化を行うツールです。 例えば、未使用のクラスを削除したり、変数名を短く意味のないもの(a, bなど)に変更したりします。 Flutterではreleaseビルド時に適用され、debugビルドでは適用されません(正確には minifyEnabled true のときに適用されます)。 debugビルド時に難読化されていると、エラーが起こった理由が分かりにくいからです。

変数名を勝手に置き換えるという処理は破壊的であるため、頻繁に問題を引き起こさないのでしょうか? 多くの場合でこのエラーは回避されます。 IDEのrenameのように、コード上の依存関係にもとづいて各シンボルのすべての出現箇所で同様の処理が行われるからです。 しかし、一部のケースでは問題となります。具体的にはリフレクションが使われている場合です。

また、アプリのメソッドやクラスに予測可能な命名規則を使用している場合(リフレクションを使用している場合など)、それらのシグネチャをエントリ ポイントとして扱い、保持するコードのカスタマイズ方法で説明したように keep ルールを指定する必要があります。keep ルールは、アプリの最終的な DEX 内でそのコードだけでなく、その元の命名規則も維持するように R8 に指示します。 ( https://developer.android.com/studio/build/shrink-code?hl=ja#obfuscate )

リフレクションとは、プログラムが実行中に自分自身の構造を読み取ることです。 クラスや関数に文字列でアクセスすることは、このリフレクションを利用しています。 通常であれば低レイヤーのコードにコンパイルされているため読み取れないはずの情報を、メタデータとして読み取っています。 本来であればコンパイル時に決定される関係を、直接、実行時に指定するようなものです。 そのため、コンパイル時にはある関数が実際に使用されるのか、されないのかを決定できません。 よって、難読化の時点ではどの変数の名前を置き換えればよいのかが決定されないため、置換されないまま残りエラーを生じることがあります。

今回エラーを生じた例では明示的にリフレクションを使用しているようには見えませんが、少なくともコンパイル時には決定できない依存関係が生じていたと考えられます。 具体的にコードを見ていくことにしましょう。 このパッケージでは、現在アクティブなコールを返す処理が次のように実装されています。 ( GitHub )

fun getDataActiveCallsForFlutter(context: Context?): ArrayList<Map<String, Any?>> {
    val json = getString(context, "ACTIVE_CALLS", "[]")
    return Utils.getGsonInstance().fromJson(json, object: TypeToken<ArrayList<Map<String, Any?>>>() {}.type)
}

ここではGsonが使用されています。Gsonとは、JSONデータとJavaオブジェクトを容易に対応付けられるライブラリです。 myObject.something = jsonData['something'] のような冗長な記載が不要となります。 実はこのGsonがリフレクションを利用しているのです。 実行時にしか分からないJSONの文字列を、クラスのフィールド名と対応付ける必要があるからですね。 もしかしたら、 Map<String, Any?> という曖昧な型でなければ、Proguardによる難読化の際に2つがうまく対応付けられるために、問題は生じないかもしれません。 しかし、Flutterとの連携を考えると、Platform channelsで渡せる型は限られているので、この実装は仕方がないかもしれません。

考察できなかった部分

今回の考察は主にAndroidを対象としたものです。iOSでは、なぜ同様の問題が生じないのかを検証する必要があります。

また、この問題が起こる理由に基づけば、もっと多くのパッケージでこの問題が生じても良いはずです。しかし、実際はこの原因を特定するのがやや難しかった程度には珍しいエラーのようです。つまり、多くのパッケージでは何らかの対策がされているか、自然に回避されている可能性が高く、その実装について調査される必要があります。

まとめ

releaseビルドにおいてのみ変数名が短縮される場合、難読化処理の影響が考えられます。特定のパッケージの特定のクラスについて難読化処理を無効化することで、この問題を回避できます。


Comments

記事一覧へ