ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

DataBindingでLoading

こんにちは、スマホチームのあいばです。
最近思い立ってオンライン英会話に入会しました。リモートの時には自宅でせっせとランチ英会話しているのですがまだまだ効果は見えません。。
最近お仕事の方はiOS中心だったのですが、Android案件も盛り上がって来たのでおさらいしています。
そこで今回はGithubBrowserSample を読み、処理中のProgressBarの表示についてどんなことしてるか見てみようと思います。

GithubBrowserSample概要

リポジトリ検索、リポジトリ詳細、ユーザ情報の3画面からなるアプリです。
今回はリポジトリ詳細のコードから引用して説明したいと思います。(この機能を選んだ理由は特にないです)
図は各モジュールが相互にどのようなやり取りをしているかを示しています。公式より f:id:unifa_tech:20190307100655p:plain

build.gradle

app/build.gradle
DataBindingを利用してProgressBarの表示を更新しているため設定を有効にする必要があります。

dataBinding {  
    enabled = true  
}

レイアウト

loading_state.xml
ProgressBarとリトライボタン、エラーテキストを持つレイアウトです。
変数resourceの値を参照し各Viewのvisibilityを切り替えています。
ほとんどJavaと同じようにコードを書くこともできます。詳細は公式のドキュメント でどうぞ。

    <layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto">
        <data>
            <import type="com.android.example.github.vo.Resource" />
            <import type="com.android.example.github.vo.Status" />
            <!-- 変数を宣言しレイアウトファイル内のバインディング式で使えるようにする -->
            <variable
                name="resource"
                type="Resource" />
            <variable
                name="callback"
                type="com.android.example.github.ui.common.RetryCallback" />
        </data>

        <LinearLayout
            android:orientation="vertical"
            <!-- カスタムセッターでvisibilityを設定 -->
            <!-- データがセットされたら非表示になる -->
            app:visibleGone="@{resource.data == null}"
            android:layout_width="wrap_content"
            android:gravity="center"
            android:padding="@dimen/default_margin"
            android:layout_height="wrap_content">
            <ProgressBar
                <!-- ローディング中は表示する -->
                app:visibleGone="@{resource.status == Status.LOADING}"
                style="?android:attr/progressBarStyle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/progress_bar"
                android:layout_margin="8dp" />
            <Button
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@string/retry"
                android:id="@+id/retry"
                <!-- リスナーバインディングを利用してリトライを実行する -->
                android:onClick="@{() -> callback.retry()}"
                <!-- エラー時にリトライボタンを表示する -->
                app:visibleGone="@{resource.status == Status.ERROR}" />
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/error_msg"
                android:text="@{resource.message ?? @string/unknown_error}"
                <!-- エラー時にTextViewを表示する -->
                app:visibleGone="@{resource.status == Status.ERROR}" />
        </LinearLayout>
    </layout>

BindingAdapters.kt
BindingAdapterを利用してカスタム セッターを作成しています。
xmlに直接バインディング式を書いても実現できますが、レイアウトがややこしくなるのは辛いですよね。

    object BindingAdapters {
        @JvmStatic
        @BindingAdapter("visibleGone")
        fun showHide(view: View, show: Boolean) {
            view.visibility = if (show) View.VISIBLE else View.GONE
        }
    }

ちなみにカスタムセッターを使わない場合の実装はこんな感じでしょうか。 loading_state.xmlより

    <layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto">
        <data>
            <!-- Viewクラス追加 -->
            <import type="android.view.View"/>
             ...
                <LinearLayout
                android:orientation="vertical"
                <!-- 書き換え -->
                <!-- app:visibleGone="@{resource.data == null}" -->
                android:visibility="@{resource.data == null ? View.VISIBLE : View.GONE"

                android:layout_width="wrap_content"
                android:gravity="center"
                android:padding="@dimen/default_margin"
                android:layout_height="wrap_content">
                ...

repo_fragment.xml
loading_state.xmlをインクルードしています。
アプリの名前空間と変数名を使用するとインクルードされたレイアウトに変数をセットできます。

    <layout xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:android="http://schemas.android.com/apk/res/android">
        <data>
            ...
            <variable
                name="retryCallback"
                type="com.android.example.github.ui.common.RetryCallback" />
        </data>
        <androidx.constraintlayout.widget.ConstraintLayout
            android:orientation="vertical"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <include
                layout="@layout/loading_state"
                <!-- loading_state.xmlに宣言したresource, callbackに値をセット -->
                app:resource="@{(Resource) repo}"
                app:callback="@{() -> retryCallback.retry()}"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                app:layout_constraintStart_toStartOf="parent"
                android:layout_marginStart="8dp"
                app:layout_constraintEnd_toEndOf="parent"
                android:layout_marginEnd="8dp"
                app:layout_constraintBottom_toBottomOf="parent"
                android:layout_marginBottom="8dp"
                android:layout_marginTop="8dp"
                app:layout_constraintTop_toTopOf="parent" />
        </androidx.constraintlayout.widget.ConstraintLayout>
    </layout>

Repository

NetworkBoundResource.kt
sqlite DBとネットワーク共通でリソースを提供するクラスです。 このクラスでResourceの状態を管理しています。

    init {  
        // 生成時に処理中ステータスをセット
        result.value = Resource.loading(null)  
        @Suppress("LeakingThis")  
        // ローカルDBから読み込み
        val dbSource = loadFromDb()  
        result.addSource(dbSource) { data ->  
            result.removeSource(dbSource)  
            if (shouldFetch(data)) {  
                // DBから取得できなかった場合ネットワークから取得する
                fetchFromNetwork(dbSource)  
            } else {  
                result.addSource(dbSource) { newData ->  
                //DBから読み込めた場合はステータスをSuccessへ変更
                    setValue(Resource.success(newData))  
            }  
        }  
    }

コードは省略しますが、ネットワークからの取得成功/失敗のタイミングでもステータスを変更しています。 またNetworkBoundResourceはabstractになっていて、各機能のRepositoryクラスで匿名クラスを実装しカスタマイズしています。

RepoFragment.kt
FragmentではBindingクラスを生成し、値を適用しています。 RepoFragmentBindingクラスは自動生成されるクラスなのですが、名前はレイアウトファイル名から決まるようです。

    var binding by autoCleared<RepoFragmentBinding>()
    
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {  
        val dataBinding = DataBindingUtil.inflate<RepoFragmentBinding>(  
            inflater,
            R.layout.repo_fragment,
            container,
            false
        )
        // リトライボタンのイベント処理を実装
        dataBinding.retryCallback = object : RetryCallback {  
            override fun retry() {  
                repoViewModel.retry()  
            }  
        }  
        binding = dataBinding  
        return dataBinding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {  
        repoViewModel = ViewModelProviders.of(this, viewModelFactory).get(RepoViewModel::class.java)  
        repoViewModel.setId(params.owner, params.name)
        
        // LiveDataの変更を監視するためのLifecycleOwnerをセットします
        binding.setLifecycleOwner(viewLifecycleOwner)  
        binding.repo = repoViewModel.repo  
        val adapter = ContributorAdapter(dataBindingComponent, appExecutors) { contributor ->
            navController().navigate(
                RepoFragmentDirections.showUser(contributor.login) 
            )  
        } 
        this.adapter = adapter  
        binding.contributorList.adapter = adapter  
        initContributorList(repoViewModel)  
    }

最後に

以前からLoadingの表示ってストレスフルだなぁと感じていたこともあり今回調べてみました。
"リクエスト投げる前にProgressBar/Dialogを表示してコールバック受けたら消す"みたいな実装をよくみる(やってた)と思いますが、バグの宝庫なんですよね…
成功、失敗、キャンセル、画面回転…さらにリクエスト投げるまでのシーケンスも次第に複雑になっていくのでとても面倒見きれない。
自分のプロダクトに適用できるかはまだわかりませんが、このサンプルではステータスが変わればDatabindingで表示が切り替わるので余計なことを考えなくて済むのではないでしょうか。
また、LiveDataを使えばライフサイクルとも連動してくれるので組み合わせて使えば何かと便利そうです。
DataBindingでできることを色々調査できてよかったです!