Androidの一部機種で、ActivityがonCreateでNoSuchMethodExceptionが発生してクラッシュする謎の不具合に悩まされ続けていました。せめて、再現できる環境が作れれば良いのですが、クラッシュレポートから確認できる機種とOSバージョンを合わせても再現しないから厄介でした。
再現さえしてくれれば確実に修正できるのですが、再現しない以上、クラッシュレポートから想定される原因を推測して、対策バージョンをリリース、その後のレポートの変化を見て推測の精度を上げるというアプローチで時間をかけて対策するしかありません。
最初に結論を書くと、Fragmentの取り扱いに関する問題で、以下2点の対策をしておけば大丈夫でした。
- Fragmentの追加(commit)は Activity.onResume 〜 Activity.onPause の間に行う
- Fragmentのコンストラクタは必ず引数無しにする
以下、少し長くなりますが、順を追って説明します。
リリース当初は、NoSuchMethodExceptionは発生していなくて、代わりにIllegalStateExceptionが発生していました。これは単純な私の実装ミスです。
onCreateでFragmentを生成してcommitするとIllegalStateExceptionが発生します。
これは、生成したFragmentをcommitしても良いのは、onResume 〜 onPause の間だけという制約があるためです。そして、Activityが起動すると onCreate → onStart → onResume という順序でコールバックされます。つまり、onCreate呼び出しはonResume前だから、onCreateでFragmentをcommitすると必ずIllegalStatusExceptionになる「はず」です。
ですが、50機種近い機種依存テストを実施しても何故か問題が顕在化しなかったので、リリース時にこのバグを積み残してしまった結果、これに起因するクラッシュが結構な数報告されました。
この問題は、原因がハッキリしているので対策は簡単です。
私は自前のBaseActivityに「onResume中でなければ実行できない処理を滞留しておく仕組み」を作り、Fragmentのcommitはその仕組で滞留させておきonResumeで実行される形にしました。
具体的には、BaseActivityに次のようなexecuteWhileResumeというメソッドを追加します:
open class MyBaseActivity : FragmentActivity() {
private val pendingProcedures = MutableList(0) { {} }
private var pausing = true
fun executeWhileResume(procedure: () -> Unit) {
if (pausing) {
pendingProcedures.add(procedure)
} else {
procedure.invoke()
}
}
override fun onResume() {
super.onResume()
pausing = false
while (pendingProcedures.isNotEmpty()) {
pendingProcedures.removeAt(0).invoke()
}
}
override fun onPause() {
pausing = true
super.onPause()
}
}
そして、Fragmentをcommitする処理は、executeWhileResume { 処理 } とすれば、onResume後に処理が実行されるようになり、IllegalStateExceptionが発生することは無くなりました。
しかし、今度はonCreateの延長で NoSuchMethodException が発生する問題がクラッシュレポートに報告されるようになります。(ここからが本題)
NoSuchMethodException は、とあるFragmentの引数無しコンストラクタをgetConstructorしようとして発生しています。どのFragmentのコンストラクタを実行しようとしたのかは、難読化されていて分かりませんが、クラッシュレポートの報告では「p4.c0.<init>」となっていました。
まず、解せないのは、onCreateの延長でFragmentのコンストラクタが呼び出されている点です。
というのもFragmentの生成→commitは全てexecuteWhileResumeで実行しているので、onCreateからFragmentを生成する筈がありません。もちろん、xml内に埋め込んでいるFragmentも無いので、何故onCreateでこのバグが報告されたのか意味不明でした。
Stack Overflowに似たような報告があります。
https://stackoverflow.com/questions/56668934/java-lang-nosuchmethodexception-for-oncreate
最も支持されている回答は、
The activity is being restored from an instance state bundle. Part of the restore operation is recreating its fragments. (訳: アクティビティは、インスタンス状態のバンドルからリストアされています。リストア操作の一部として、そのフラグメントを再作成しています。)
Your activity has a fragment and the fragment class does not have a 0-arg constructor required by the framework. (訳: アクティビティにフラグメントがあり、フラグメントクラスにフレームワークで必要とされる0argのコンストラクタがありません。)
とのこと。
ただ、先述の通りonCreateではFragmentの生成をしていないので、当初この回答を理解することはできませんでした。(※結果的にはこの回答が正しかったですが...)
別のクラッシュレポートを確認したところ、何故か、onCreateでPreferenceに値をセットしているところでもNoSuchMethodExceptionが発生するレポートも挙がっていました。
これには混乱しました。
完全に意味不明です。
「何かしらのI/Oが発生すると、外的にFragmentが生成されるのではないか」という仮設が浮上してしまう始末の悪さ。
そこで、実装としてはかなりキモチワルイのですが、Activityの初期化シーケンスを次のように修正してみることにしました:
- onCreate では setContentView、findViewById 以外を呼び出さない
- 従来 onCreate で実行していた初期化処理全般は onResume で自前の初期化処理を once call (一度だけ呼び出す)
これで治ったら逆に意味不明です。
案の定、この対策ではレポート報告は収束しませんでした。
新たなクラッシュレポートは更に意味不明な内容で、Activity の onCreate で自前のログ記録ユーティリティでデバッグログ出力メソッド(リリースビルド時は無出力)をコールしていたのですが、その延長でNoSuchMethodExceptionが発生していました。なにゆえ???
そのデバッグログ出力メソッドは、デバッグビルドであるかを判定してLog.dでログを出力するだけの単純なものです。そして、当然リリースビルドなのでLog.dによるログ出力すらしていない筈です。つまり、何も処理を実行していないメソッド呼び出しの延長で、arg0が無い謎のFragmentのコンストラクタ(p4.c0.<init>)が実行されようとしたのです。
当初、p4.c0.<init> は難読化されたクラス名かと思いましたが、実はAndroidのソースコードでは内部的に https://p4.org と呼ばれる通信制御ライブラリが使われているので、もしかしてそのP4なのだろうか?????
参考までに以下、p4公式サイトの説明を邦訳:
P4(Programming Protocol-Independent Packet Processors)とは、ネットワーク機器のドメイン固有の言語であり、データプレーン機器(スイッチ、NIC、ルーター、フィルターなど)がどのようにパケットを処理するかを規定したものです。
P4以前は、ベンダーがネットワークでサポートされる機能を完全にコントロールしていました。また、ネットワークシリコンが可能な動作の多くを決定するため、シリコンベンダーが新機能(VXLANなど)のロールアウトをコントロールし、ロールアウトには何年もかかっていました。
P4はこのような従来のモデルを覆します。アプリケーション開発者やネットワークエンジニアは、P4を使ってネットワークに特定の動作を実装することができ、変更も数年ではなく数分で行うことができます。
出力ログを収集する怪しいウィルスでもあるのだろうか?と陰謀論のようなことを考えてしまいました。
とりあえずonCreateでのログ出力メソッドの呼び出しは(Activityのライフサイクル検証のため)デバッグ用に入れた冗長なものなので消しておくことにしました。
もう1点、実装当初、Fragmentの再生成を抑止するために、コチラの情報を参考にして、onCreateで super.onCreate(savedInstanceState?.apply { remove("android:support:fragments") }) を実行するようにしていたのですが、コレが問題を引き起こす原因になっている可能性も考えられるかも...とも思うようになりました。というのも、再生成を抑止するために「super.onCreate(null)」を呼び出すと「深刻な問題」を引き起こす可能性があるとコチラに書かれていたので、このロジック的に理解できない呼び出しが影響して、今の意味不明な状態が起きているのではないかと推測しました。
実のところ、この処置(Fragmentの再生性抑止)は、当初のIllegalStateExceptionで血迷って入れてしまったコードなので、executeWhilteResumeの仕組みを導入した時点で削除すべきでした。
という訳で、
- BaseActivityのログ出力処理を削除
- super.onCreate(savedInstanceState?.apply { remove("android:support:fragments") }) → super.onCreate(savedInstanceState) に変更
という修正を入れて、「これで治ってくれ!」と願いながらリリース。
翌日、願うようにCrashlyticsコンソール(この対策の少し前に導入)を覗いてみたのですが、残念ながらまだNoSuchMethodExceptionの報告は収束していません。
ただ、NoSuchMethodException が発生する状況は変わらないのですが、p4.c0.<init> ではなく r4.c0.<init> が呼び出される形になったので、(当然ですが)やはりP4は関係無く、単に難読化で変更されたクラス名だったようです。
難読化、正直居るん? と思いつつあります。
Crashlyticsコンソールで問題発生時のコンディションを詳しく確認した結果、何故onCreateで何らかのFragmentのarg0コンストラクタが実行されたのかを理解することができました。
自分のActivityのonCreateから、NoSuchMethodExceptionが発生するまでのスタックトレースを見てみると、androidx.fragment.app.FragmentController.dispatchCreateの延長でr4.c0.<init>がコールされていることが分かりスッキリ。
つまり、FragmentControllerの仕組みでActivityが再生成されれば現象が発生するようです。そして、起動すると即落ちする現象が発生するユーザ環境では、何故かActivity生成時にこの再生成シーケンスが呼び出されたのであろうことが理解できました。(この情報は、Developer Consoleのクラッシュレポートでは分からず、Crashlyticsを導入して初めて把握できるようになります)
Fragmentサブクラスに引数無しコンストラクタを定義してもAndroid Studioが一切警告してくれなかった(昔はちゃんと警告してくれていた)ので、最新のTarget SDKならその問題が治ったのかと勘違いしてしまい、引数付きのFragmentコンストラクタを大量に作ってしまっていたので、これがFragmentControllerの仕組みとミスマッチだったようです。
という訳で、
- 自前Fragmentはすべて引数無しに変更
- 自前Fragmentは例外なく companion object { fun newInstance(... なりでインスタンス化する仕様に変更
という対処をしてリリース。
なお、どういった条件でActivityの再生性が発生するのかは依然として分かりませんが、一度OSホーム画面に戻り、メモリ所要量の多いアプリを複数個起動してからアプリに復帰すれば、Activityのライフサイクル(App process killed)に従ってActivityの再生成が行われるので、その方式でチェック。
そして、「今度こそは治ってくれ...」と切実に願いながらリリース。
無事、NoSuchMethodExceptionのクラッシュレポートは収束したようです!
ただし、今度はまた別の問題(TransactionTooLargeException)がレポートされてきたので、まだしばらくは休めそうにないですが。