LiveDataの監視にktxを使わなくなったら起きたこと

5 min read読了の目安(約5300字

はじめに

これは Mobility Technologies Advent Calendar 2020 の11日目の記事です。

t_sofueです。よろしくお願いします。
今年、Androidアプリを開発している中で遭遇した問題について書きたいと思います。
具体的にはLiveDataのobserve関数についてです。

遭遇した問題

まずはどのような問題に遭遇したかです。簡単に言うとFragmentを表示して閉じて、再度表示すると例外が発生してクラッシュするという問題でした。

発生した例外は以下になります。

java.lang.IllegalArgumentException: Cannot add the same observer with different lifecycles

例外を投げているのはこちら

問題が起きたクラスについて

今回の問題が発生したクラスはざっくりと以下のようなクラスでした。
説明のために適当にログを出力しているだけのクラスです。
data1を監視する際のラムダはラムダ外のプロパティにアクセスしているが、data2を監視するラムダはラムダ外へのアクセスがない。

例外発生タイミングはdata2を監視したタイミングです。data1を監視したタイミングでは例外は発生していません。

class MainFragment : Fragment() {
    ...
    private var isHoge: Boolean = false
    ...
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        viewModel = ViewModelProvider(this).get(MainViewModel::class.java)

        viewModel.data1.observe(viewLifecycleOwner) {
            Log.d(TAG, "hoge:$isHoge")
        }

        viewModel.data2.observe(viewLifecycleOwner) {
            Log.d(TAG, "fuga")
        }
    }
}

原因

調査していくとFragmentがリークしていることに気づきました。
childFragmentManagerを用いる必要がある処理をparentFragmentManagerを用いていることが原因でFragmentが破棄されずに残り、LiveDataを監視し続けていたのです。
そのため破棄されずに残っているFragmentのLifecycleと新たに生成されたFragmentのLifecycleで同じObserverを用いて監視を開始したために例外が発生していました。

なぞ

  1. なぜクラッシュするようになったのだろう?以前からリークする問題は存在していたのに
  2. なぜdata1を監視しても例外が発生しないのだろう?
  3. なぜ同じObserverになるのだろう?

クラッシュするようになった原因

問題となったクラスに加えた変更点はimport文の削除だけでした。削除したのはこの1行です。

import androidx.lifecycle.observe

なぜ削除していたのかと言うとKotlin 1.4を使うようになったことにより不要になったためです。
Kotlin 1.4からSAM変換が賢くなりました。そのため以前ではLiveDataを監視する際のObserverをラムダ式で記述するにはLiveDataの拡張関数を用いていいたと思います。
しかし、1.4以降は拡張関数を用いないでもラムダ式で記述できるようになりました。
公式サイトにも記載がありますが既にこの拡張関数はDeprecatedとなっています。
LiveData拡張関数のコードでも1.4以降は不要なのでimport androidx.lifecycle.observeは消しちゃってねって書いてあります。

このimport文を復活させるとリークしていてもクラッシュしません。

一緒に開発しているリーダーからラムダについてはKotlin in Actionの5.4に書いてあるよと教えていただきました。
(いつもありがとございます!)

説明ではラムダ内で外のプロパティにアクセスしていると匿名クラスが生成されて呼び出しのたびに、その匿名クラスのインスタンスを新たに生成する。ラムダ外へのアクセスが無い場合には呼び出し間で再利用されるとありました。

今回のケースではdata2を監視するラムダは外へのアクセスが無いので再利用されることになります。この説明でやっと同じObserverになる理由がわかりました。

また、inline関数にラムダをわたしても匿名クラスは生成されないとの説明もありました。
そのためLiveDataの拡張関数を用いた場合にはObserverは再利用されることなく都度生成されるため例外が発生しなかったと思われます。

LiveData拡張関数import有無での差

試しに2つのコードが実際にどのような処理に変換されているのかをAndroid Studioで以下の操作を行い確認しました。

Tools -> Kotlin -> Show Kotlin Bytecode -> decompile
この手順で生成されたJavaのコードをobserve関数の箇所を抜粋して見てみたいと思います。

拡張関数をimportした場合に生成されるコード

      LifecycleOwner owner$iv = var6;
      int $i$f$observe = false;
      Observer wrappedObserver$iv = (Observer)(new MainFragment$onActivityCreated$$inlined$observe$1(this));
      $this$observe$iv.observe(owner$iv, wrappedObserver$iv);
      var10000 = this.viewModel;
      if (var10000 == null) {
         Intrinsics.throwUninitializedPropertyAccessException("viewModel");
      }

      $this$observe$iv = var10000.getData2();
      var6 = this.getViewLifecycleOwner();
      Intrinsics.checkNotNullExpressionValue(var6, "viewLifecycleOwner");
      owner$iv = var6;
      $i$f$observe = false;
      wrappedObserver$iv = (Observer)(new MainFragment$onActivityCreated$$inlined$observe$2());
      $this$observe$iv.observe(owner$iv, wrappedObserver$iv);

observe関数に渡すObserverは都度生成するコードになっていることがわかると思います。そのためLiveDataの拡張関数を使っている場合には今回の例外は発生しない。

拡張関数をimportしない場合に生成されるコード

      var10000.getData1().observe(this.getViewLifecycleOwner(), (Observer)(new Observer() {
         // $FF: synthetic method
         // $FF: bridge method
         public void onChanged(Object var1) {
            this.onChanged((Boolean)var1);
         }

         public final void onChanged(Boolean it) {
            Log.d("MainFragment", "hoge:" + MainFragment.this.isHoge);
         }
      }));
      var10000 = this.viewModel;
      if (var10000 == null) {
         Intrinsics.throwUninitializedPropertyAccessException("viewModel");
      }

      var10000.getData2().observe(this.getViewLifecycleOwner(), (Observer)null.INSTANCE);

data1を監視するObserverは都度生成するようになっているが、data2を監視するObserverはINSTANCEとなっており毎回同じインスタンスを使うようになっている!
そのため、Cannot add the same observer with different lifecyclesと例外が発生する。

さいごに

Kotlin 1.4からLiveDataの監視時にラムダ式を用いたい場合にktxを用いる必要がなくなりました。そのため、既にKotlin 1.4を使っている場合にはLiveDataの拡張関数のimport文を削除してしまって問題ないです。
しかし、今回のケースのように他の問題(私の場合にはFragmentのリーク)と重なることでクラッシュすることもあるので少し注意が必要になります。
まだまだKotlinを理解できていませんでした。年末にしっかりとKotlin in Actionを理解したいです。
ありがとうございました。

Mobility Technologies Advent Calendar 2020 の12日目は、ejiさんによる 1年間育児休業を取った話 です。