2021年8月21日土曜日

Activity遷移でTransactionTooLargeExceptionが発生する問題

Activityから別のActivityへ遷移した時、Bundleに保存されている情報がスタックされるのですが、Bundleにスタックできる情報量は1MB以下に制限されていて、1MBを超えるとTransactionTooLargeExceptionでアプリがクラッシュします。

ActivityのBundleだけでなく、FragmentのBundleの情報もスタックされる点に注意が必要です。(これだけが言いたかった)

正直なところ、「Activityの再生成メカニズムはまともに機能できていないのではないか?」と思う今日この頃です。再生成メカニズムというのは具体的には、Activityのライフサイクルの解説図面で言うところの「App process killed」のシーケンスです。

「App process killed」のシーケンスが実行されると、例えばシングルトンで保持しているインスタンス類は全てnullになってロストします。つまり、onCreateはその状態で再実行される可能性があるので、onCreateではシングルトンのデータ復旧処理などを記述しておく必要があります。

その設計思想通りに設計されたアプリが果たしてどの程度あるのか。

少なくとも、私は見たことがありません。

例えば、Google謹製のYouTubeアプリですら、初期状態からのリラン(プロセスリスタートと同等)をしています。

iOSにはこのような中途半端な復旧シーケンスは無くて、カーネルにアプリのプロセスが停止させられた場合、プロセスリスタートになります。

要は、以下が本来あるべき姿だと思います。

Activity Life cycleの本来あるべき姿(私見)

カーネルによりプロセスが停止された場合、アプリプロセスはリスタートで良いと思います。

むしろ、カーネルによる停止時にonDestroy実行保証してくれる方が重要だと思います。それが実現されれば、onDestroyでアプリプロセスがリスタートされた時に必要な情報をアプリ自身で記憶しておき、リスタート時にアプリ自身で復旧シーケンスを実行することが可能なので。

もう少し言ってしまうと、下図ぐらいシンプルにしてしまった方が良かったのではないかなと思います。

  • Start/Resume → onForegroundでまとめる
  • Pause/Stop → onBackgroundでまとめる
  • onRestart相当のものは無くても良い(必要ならonForegroundの引数でOK)
Androidのライフサイクルがこれぐらいシンプルだったら、そもそもTransactionTooLargeExceptionや前の記事で書いたNoSuchMethodExceptionなどというトラブルを生み出さなくても良かった筈。

FragmentActivityがonCreateでNoSuchMethodExceptionで落ちる(アプリが起動時に落ちるetc)

Androidの一部機種で、ActivityがonCreateでNoSuchMethodExceptionが発生してクラッシュする謎の不具合に悩まされ続けていました。せめて、再現できる環境が作れれば良いのですが、クラッシュレポートから確認できる機種とOSバージョンを合わせても再現しないから厄介でした。

再現さえしてくれれば確実に修正できるのですが、再現しない以上、クラッシュレポートから想定される原因を推測して、対策バージョンをリリース、その後のレポートの変化を見て推測の精度を上げるというアプローチで時間をかけて対策するしかありません。

最初に結論を書くと、Fragmentの取り扱いに関する問題で、以下2点の対策をしておけば大丈夫でした。

  1. Fragmentの追加(commit)は Activity.onResume 〜 Activity.onPause の間に行う
  2. 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)がレポートされてきたので、まだしばらくは休めそうにないですが。


2021年8月3日火曜日

Android: EditTextのバックスペース(削除)キーが効かない問題

かなり久々にAndroidのプログラミングネタです。

EditText のバックスペースキー(KeyEvent.KEYCODE_DEL)が一部端末で効かない問題があり、原因を調べてみました。

調査のため、EditText::setOnKeyListener を仕掛けてみたのですが、そもそもバックスペースキーのイベントが飛んできていないらしい。

そこで、試しに Android 9(P) 以降で追加された addOnUnhandledKeyEventListener という、その名の通りハンドルされなかったキーイベントを検出するリスナを仕掛けてみたところ、反応がありました。

という訳で、EditText::addOnUnhandledKeyEventListener がファイアしたらdispatchKeyEventをするという雑な対処を入れてみたところ、正常に動作するようになりました。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
edit.addOnUnhandledKeyEventListener { v, event ->
v.dispatchKeyEvent(event)
true
}
}

ただし、1枚のFragment / Activityに複数のEditTextがある場合、必ずしも編集中のEditTextのOnUnhandledKeyEventListener がファイアされる訳ではないようです。どうも固定のプライオリティ順序でイベントがファイアする(まるでバグのような)仕様のようです。

という訳で、複数のEditTextがある場合、hasFocusでフォーカスを持っているEditTextに対してdispatchKeyEventをしてあげるというクソのような対処が必要になるようです。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val listener = View.OnUnhandledKeyEventListener { v, event ->
when {
edit1.hasFocus() -> {
edit1.dispatchKeyEvent(event)
true
}
edit2.hasFocus() -> {
edit2.dispatchKeyEvent(event)
true
}
else -> {
false
}
}
}
edit1.addOnUnhandledKeyEventListener(listener)
edit2.addOnUnhandledKeyEventListener(listener)
}

そもそも、何故バックスペースキーのイベント(カーソルキーのイベントなども同様)がunhandledになってしまったのかが少し気になるところなので、View::addOnUnhandledKeyEventListenerのドキュメンを確認してみましたが、有益な情報は記述されていないようです。たぶん、Pieで実装された何かしらのOSのエンハンスに引っ張られてunhandledなイベントをアプリ側で捕捉しなければならない何らかのアレが起こったのであろう...と想像。


合理的ではないものを作りたい

ここ最近、実機版の東方VGSの開発が忙しくて、東方VGSの曲追加が滞っています。 東方VGS(実機版)のデザインを作りながら検討中。基本レトロUIベースですがシークバーはモダンに倣おうかな…とか pic.twitter.com/YOYprlDsYD — SUZUKI PLAN (...