Androidのライフサイクルで活きるUI周りのRxJava活用例3選

こんにちは。Pairs JPのAndroidエンジニアの栗村貴尚(@t-kurimura)です。このAdventCalendarは、3回目の登場となります。

 さて、eureka Native Advent Calendar 2017の11日目は、昨日の@yuyakaidoさんの「サポート機能でアプリ開発をより効率的に!」に続いて、RxJavaのオペレーターの話です。

 このAdventCalendarの7日目で「RxJava,Kotlin,Databindingでイケてる入力フォームをスッキリ実装する」をお伝えしました。この中ではcombineLatestにを利用したフォームの入力項目の同期的なバリデーションを実装例にお伝えしました。今回の記事では、それに関連しPairs JP Android版で実際に活躍しているのUIまわりでのRxJavaのオペレーターの3つの使用例をとその実装についてご紹介します。

ご紹介する活用例

  1. Fragmentの切り替え時の親Activityから子Fragmentへの通知 – throttleLast
  2. スクロールで最下部到達までの監視 – distinctUntillChange
  3. ローディングのちらつき防止 – zipとtimer

Fragmentの切り替え時の親Activityから子Fragmentへの通知

ユースケース

 最近はAndroidでも多くのアプリで、メイン画面での下タブやドロワーでの画面切り替えを見るようになりました。これらの画面構成の多くの場合はすべてのタブやドロワーそのものを持つ親となるActivityがいると思います。

 こうした構成の場合に、画面が特定のところに切り替わったときなどの任意のタイミングで、その画面の情報を再度読み込み直したいケースがあると思います。しかし、Androidのライフサイクル上のメソッドではFragment自身は既に生成が行われていると自分のFramgnetに切り替えられたことは気づけません。そのFragmentを持つViewを切り替えている親Activityが知らせてあげる必要があります。

注意点

このとき、子となるFragmentに、Publicメソッドを作ってActivityから呼ぶのは危険です。Fragmentが他の画面でのメモリー消費の関係などで、一度onDestroyViewが呼ばれている可能性があるからです。onDestroyViewが呼ばれていて、メンバー変数で持っているViewのプロパテイーが初期化されていると、子Fragmentへの通知からそのViewへアクセスしようとするとNullPointerExceptionが発生します。

    //  ----- 子Fragment ----- 
    public void onReload() {
        // ここにきている時点で既にFragmentの状態は保持されていない可能性
        usecase.fetchUserData()
            .subscribeOn(Schedulers.io())
            .obserbeOn(AndroidSchedulers.mainThread())
            .subscribe(
                { nameTextView.setText(it.name) }, // ここでNullPointerExceptionを吐く可能性 
                [ Timber.e(it, it.message) }
            )
    }

    //  ----- 親Activity ----- 
    @Override protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.parent_activity);
        viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener 
          override fun onPageSelected(position: Int) {
            val previousFragment = viewPager.adapter.findFragmentByPosition(viewPager, position)
            fragment.onReload()
          }
          // ...省略
        })
 }

解決策

  //  ----- 親Activity ----- 
  private val onPageSelectedSubject = PublishSubject.create<MainTabItem>()

  val onSearchPageSelectedSubject = PublishSubject.create<Unit>()

  @Override protected fun onCreate(savedInstanceState: Bundle) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.parent_activity);
    navigationBar.setOnTabSelectListener(new OnTabSelectListener () {
      @Override public void onTabSelected(@IdRes int tabId) {
        val item = MainTabItem.getItemByResource(tabId) // 5つのどのタブかをEnumをクラスで判定
        switchCurrentTabTo(item) // 画面と下タブの切り替え
        onPageSelectedSubject.onNext(item) // 切り替えた画面の更新
      }
    });
    onPageSelectedSubject.throttleLast(1000, TimeUnit.MILLISECONDS) // 1000ミリ秒間の最後の1回の値をSubscribeに送る
        .observeOn(Schedulers.io()) // 子Fragmentでメインスレッド指定
        .subscribe(
            {
              when (it) {
                MainTabItem.SEARCH -> onSearchPageSelectedSubject.onNext(Unit)
                else -> {
                }
              }
            }
            , { Timber.e(it, it.message) }
        )
  }


  // ----- 子Fragment ----- 
  private lateinit val compositeDisposable

  override fun onViewCreated(view: View?, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
     compositeDisposable = CompositeDisposable()

     (getActivity as ParentActivity).onSearchPageSelectedSubject
        .onBackpressureLatest()
        .obserbeOn(AndroidSchedulers.mainThread())
        .subscribe({
            // 画面更新処理

        },
        { Timber.e(it, it.message) }).addTo(compositeDisposable)
    } 

    override fun onDestroyView() {
        subscriptions.unsubscribe() // 画面が死ぬと同時に購読解除 -> NPEが起こりうらない
        super.onDestroyView() // Viewが消される前に呼ぶ
    }

ポイント

この解決策では、もともとActivityからFragmentへ能動的な通知を行なっていた実装を、Activityで発火しているイベントをFragment側から能動的に監視するという方法に変更しています。このことでFragmentのライフサイクルに合わせて購読を解除したり、値を捨てたりするポリシーを定めたりできます。

また、Activity側ではthrottleLastというオペレーターを使って必要以上の量の情報がFragmentに流れてしまうことを防いでいます。throttleLast(1000, TimeUnit.MILLISECONDS) は、1000ミリ秒間の間でもっとも最後にきた値をそのしたに流すオペレーターです。連打処理などにも使えますが、連打処理の場合は、連打していなくても1000ミリ秒間はユーザーを待たせてしまう点がネックです。今回の場合は、いろいろなタブの連打時、すべての画面が更新されることを防いでいます。なので、連打されている間は更新の必要がなく、最後にタップされたタブの値のみが伝わるようにしています。

throttleFirst公式ドキュメント

スクロールで最下部到達までの監視

ユースケース

 Androidアプリを作る場合大抵の場合、ListView,GridView,RecyclerView,WebViewといったレイアウトを利用してスクロールを実装すると思います。長めのスクロールを実装し、最後のコンテンツに来ると「トップへ戻る」というボタンが出現するケースがあると思います。この場合、スクロールする対象のViewの高さ(Height)と現在のスクロール量(ScrollY)を監視し続けながらボタンを表示したり消したりする必要があります。
 

実装例

    private val isTouchedToBottomSubject = PublishSubject.create<Boolean>() // タッチしているかどうかをemitするストリーム

    val scrollContentHeight = scrollView.getChildAt(0).height // スクロールの高さを取得

    // スクロール量を監視
    scrollView.setOnScrollChangeListener({_, scrollX, scrollY, _, _ ->
      val isTouchedToBottom = (scrollContentHeight == scrollY + displayHeight) // Viewの高さがスクロール量と一致しているか
      isTouchedToBottomSubject.onNext(isTouchedToBottom) // 到達しているかどうかをemit
    })

    // タッチしているかどうかをemitするストリームを監視
    isTouchedToBottomSubject.distinctUntilChanged()
        .subscribe { 
          if(it) {
            startAppearAnimation()
          } else {
            startHideAnimation()
          }
        }

ポイント

スクロールが最下部に到達しているかどうかをスクロールのリスナーが呼ばれる度に、PublishSubjectにonNextで流しています。このままこのPublishSubjectをSubscribeすると、同じ値でも何度でも流れてしまうので、消えるアニメーションの途中で消えるアニメーションが再度始まってしまったり、その逆もまた然りです。

そこでdistinctUntilChangedというオペレーターを利用しています。この値は一つ前の値をキャッシュし、流れてきた値をその直前の値と比較して異なっていたらfilterせずに下に流しています。これを行うことによって「最下部に到達している状態」と「最下部に到達していない状態」が切り替わった時にだけ、値が流れるようにしています。

distinctUntilChanged公式ドキュメント

ローディングのちらつき防止

ユースケース

 次の画面に遷移する時に確実にAPIのコールバックを受けておく必要がある処理(ユーザー登録など)では、ローディングを表示することが望ましいかと思います。しかし、そのローディングは通信制限の3Gではしっかり数秒間と表示されその意味が大きいかもしれませんが、WiFi環境では即座に処理が終了してしまったりすると、一瞬の画面変化がちらついているように見えて、親切で表示しているローディングがかえって、UXとしてはネガティブに働いてしまうともあると思います。そこで、「API通信がどれだけ早く終わっても最低1000ミリ秒は待たせるが、それ以上になった場合は通信終了を待つ」といったことがしたい場合、今回の例が有用です。ローディングで5000ミリ秒間待たせてその間に新しい機能の告知するなどもありうるケースかと思います。

実装例

    registerButton.setOnClickListener { 

      Observable.zip(
               client.postMyData(),
               Observable.timer(1000, TimeUnit.MILLISECONDS), 
               BiFunction<Boolean, Long, Boolean> { data, _ -> data }
            )
          .subscribeOn(Schedulers.io())
          .observeOn(AndroidSchedulers.mainThread())
          .doOnSubscribe { showLoading() }
          .doOnDispose { hideLoading() }
          .subscirbe(
              { moveToNextPage() },
              { Timber.e(e, e.message) }
          )
    }

ポイント

3000ミリ秒でonNextを1つ返すObservableとAPIのレスポンスを返すObservableが待ち合わせしています。双方のonNextが1つずつきて初めて下に流れます。つまり、「3000ミリ秒」と「APIのレスポンス」の遅い方のタイミングで次のページに進めます。

doOnSubscribe で購読した瞬間にローディングが表示され、 doOnDispose でonCompleteかonErrorのどちらかがきた時点でローディングが消えるようになっています。これによって、ローディングの表示によってかえってUXが低くなってしまう問題を防いでいます。

zip公式ドキュメント

おわりに

Pairs JP Android版では、API通信・DB疎通と言ったインフラ層から画面を描画するUI層まで様々な部分でRxJavaを利用しています。もちろん無下に使うと気づかぬうちにメモリーの消費量を増やしてしまうこともありますが、適切に使うことによって、Androidの複雑なライフサイクルの中でもコードの安全性を保てたり、UXを向上させることができたりします。
卑近な例でしたが、なかなかな普段使うことのないオペレーターもあったかと思うので、ご参考になれば幸いです。

 明日は、Lemonさんのスクラムに関する記事です。私自身、スクラムマスターの端くれくらいではいるつもりなので、楽しみな記事です!

  • このエントリーをはてなブックマークに追加

エウレカでは、一緒に働いていただける方を絶賛募集中です。募集中の職種はこちらからご確認ください!皆様のエントリーをお待ちしております!

Recommend

ユーザーストーリーマッピングをやってみました

社内ツールを駆使してExcelへのレポートを自動化した話