ViewPagerの先読みデータ取得をやめる


これは[Yahoo! JAPAN 2018年新卒有志 Advent Calendar 2018](https://adventar.org/calendars/3388)の16日目の記事です。
前回は @44x1carbon さんによる「日本語プログラミングのすヽめ」でした。

## 背景

先日、業務で「メイン画面のタブの左に一つカテゴリーを増やしたいが、サーバーの負荷を考えてほしい」と言われました。
ViewPagerでFragmentPagerAdapterやFragmentStatePagerAdapterを利用している場合、デフォルトでは今表示しているFragmentの左右1つずつのFragmentも読み込まれるようになっています。
ユーザーがスワイプしたときに滑らかに遷移するためには必要なのですが、多くのデータを取得する必要があった場合、

- 表示していないのに通信が発生する。(無駄にパケットを食う)
- 必要以上にサーバーに負荷がかかる。

といった問題があります。
これにどう対処したかについて書いていきます。

## ViewPager#setOffsetScreenPageLimit

ViewPagerにはキャッシュする画面数を変更する``setOffSecreenPageLimit``というメソッドが存在します。
例えば、``viewPager.offscreenPageLimit = 2``のようにしておけば、表示している左右2つずつのFragmentが読み込まれます。(デフォルトは1)

「じゃあ``viewPager.offscreenPageLimit = 0``すればいいじゃん。」と思うのですが、``setOffScreenPageLimit``の実装を見ると、

```java
public void setOffscreenPageLimit(int limit) {
    if (limit < DEFAULT_OFFSCREEN_PAGES) {
        Log.w(TAG, "Requested offscreen page limit " + limit + " too small; defaulting to "
                + DEFAULT_OFFSCREEN_PAGES);
        limit = DEFAULT_OFFSCREEN_PAGES;
    }
    if (limit != mOffscreenPageLimit) {
        mOffscreenPageLimit = limit;
        populate();
    }
}
```
のようになっており、DEFAULT_OFFSCREEN_PAGES(つまり1)未満の値をセットできません。

## Fragment#setUserVisibleHint

Fragmentには``setUserVisibleHint``というほぼViewPagerのためだけのメソッドが存在しています。
ViewPagerでの表示Fragmentが変更されるたびに呼び出され、現在そのFragmnetが表示されているかどうかがわたってきます。
そこで、``onActivityCreated``などに書いているデータ取得処理をここで行ってしまえば、「最初に表示されたときのみデータを取得する」という処理を書くことができます。

```kotlin
override fun setUserVisibleHint(isVisibleToUser: Boolean) {
    val lastValue = userVisibleHint
    super.setUserVisibleHint(isVisibleToUser)
    if (!lastValue && isVisibleToUser) {
        // TODO: データ取得処理
    }
}
```

しかし、``setUserVisibleHint``は``onViewCreated``や``onActivityCreated``より後に呼ばれる可能性があります。
AACのViewModelを``onActivityCreated``などで生成し、そのViewModelからデータ取得を行おうとすると``UninitializedPropertyAccessException``が発生する可能性があります。

```kotlin
class PageFragment : Fragment() {
    private lateinit var viewModel: FeaturePageViewModel
    
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)

        val viewModel = ViewModelProviders.of(
            parentFragment!!, 
            PageViewModel.Factory(activity!!.application)
        ).get(PageViewModel::class.java)
    }

    override fun setUserVisibleHint(isVisibleToUser: Boolean) {
        val lastValue = userVisibleHint
        super.setUserVisibleHint(isVisibleToUser)
        if (!lastValue && isVisibleToUser) {
            // データ取得処理
            viewModel.load() //UninitializedPropertyAccessException発生
        }
    }
}
```

そこで、AACのLifecycleを利用します。
``getLifecycle.getCurrentState()``で現在のStateを確認できるため、以下のようにします。

```kotlin
override fun setUserVisibleHint(isVisibleToUser: Boolean) {
    val lastValue = userVisibleHint
    super.setUserVisibleHint(isVisibleToUser)
    if (!lastValue && isVisibleToUser) {
        if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
          // データ取得処理
          viewModel.load()
        }
    }
}
```

これで画面が表示されたときのみデータを取得するという処理ができました。

## 最後に

今回はデータ取得のタイミングをずらすことで対応しましたが、ユーザー体験的にはViewPagerは先読みされていたほうが快適です。
やむを得ない事情がある場合のみ対応するようにしましょう。

また、今回は書きませんでしたが、上の例ではPageFragmentが持つPageViewModelのスコープを``this``ではなく``parentFragment``や``activity``にすることで、ViewPagerのキャッシュからPageFragmentが外れても取得したデータが保持できるようになっています。
この場合、``viewModel.load()``する度にViewModelに追加していくような処理になっていると、スワイプを繰り返した場合に複数回データ取得処理が走るため、それを防ぐ必要があります。
``viewModel.load()``で確認するなどの処理を行うようにしましょう。


## 参考

- [ViewPagerのキャッシュする画面数を変更する](http://outcesticide.hatenablog.com/entry/ViewPager_setOffScreenPageLimit)
- [Handling Lifecycles with Lifecycle-Aware Components](https://developer.android.com/topic/libraries/architecture/lifecycle#implementing-lco)