In Out In

主にAndroidの技術のはなし

DataBindingとRecyclerViewをもう少し便利に使う話

初めてブログを書き始めます。

コミュ障なので勉強会とかでは発表する勇気がないのでこうやってひっそりと記事にして情報を発信していく所存です。

誰かの参考になれば幸いでございます。

RecyclerViewとDataBindingとフィードUI

フィードを表示するビューをRecyclerViewで作って、かつ最近なんかはGroupieを使ってDataBindingの自動生成クラスをBindableItemに詰めて、onBindで data のタグ内に定義してあったkotlinのdataクラスをセットする、なんて実装は結構簡単で、パフォーマンスも良く、誰がみてもイメージしやすい形なのではないかなと思います。 GroupieはBindableItemを継承して作ったItemの種類ごとにViewTypeを判別してくれたり、クリックリスナーもItemのコンストラクタに入れてあげると、onBindの引数からpositionが取れたり、コンストラクタに一緒に詰めていたkotlin dataクラスをそのまま引数で返したりもできるので、Adapterを一から作るよりもエンジニアによって実装方法が大きく変わったりしないのでとても良いなと思っています。

1つのフィードUIでユーザー体験を成立させるのは難しい

特に最近のアプリは機能が多様化しているし、SNSなアプリを取ってみても、 いいね機能コメント機能アーカイブ的機能シェア機能各画面への遷移 など1つのフィードで出来ることが増えてきました。さらにはフィードの合間で広告が入ったり、地味に機能が異なる似たフィードUI、他のユーザーのおすすめ、運営からのピックアップ、フォーマットが異なるメディアコンテンツ、などなど。デザイン要件は広がる一方です。

普段使っているライブラリをさらに賢く使いたい

業務でコードを書いていると、スケジュールの厳しさやその日の気分に負けて安易なコーディングを選択してしまうかもしれません。しかし、そのチームやあなたの怠惰はいつの日かチームもしくはあなたの首を締めにくるでしょう。リファクタリングと機能開発は両立していかなければいけないのです。しかし難しいのは、自分の書いたコードがそもそも安易なのかすら分からないことではないでしょうか。常に隣にスーパーエンジニアが居てくれたらどれほどいいことか。話は少し外れましたが、こういったエンジニアを悩ませてくる機能開発に対して、今回はフィードUIを例にとってみて、こういうことやると良いのでは?みたいな意見を少ないですが書いていこうかなと思います。

複数の複雑化していくフィードUIに対するアプローチ

実装的な視点 : Groupieを使う

  • これに尽きる気がします。余計な実装は省いてくれつつ、痒いところに手が届いてくれる。内部ではDiffUtilを使ってくれているし、executePendingBindings()も呼んでくれるし、Databindingを使うこともできて各クラスの記述を少なく出来るし可読性も上がります。部分的な更新処理も比較的簡単に書くことができて非常に便利です。 そしてAdapterクラスが各画面ごとに増えていくのが防げるって結構メリットだなと思っています。似た要件を持った画面って結構振り返ると多かったりすると思っていて、そんな時にItem単位で見た目を管理できるっていうのは非常に柔軟性が高いと思います。 Groupieやその他RecyclerViewに関するTipsが載っている詳細は有志の方々が記事で残してくださっているのでそちらを参考にしてくださいませ。

  • executePendingBindings()系の記事では一番まとまっているかなと思います。 techbooster.org

  • Groupieがある昨今はDiffUtilをベタで使うってあまりない気がしますが。 qiita.com

  • 実務での経験と紐付いていて参考になるところしかないです。 qiita.com

  • ラクして実装したい場合はこういうの使うのも良いのかも。 qiita.com

実装的な視点 : 共通化を検討する

AbstractActivityを作ろうってわけじゃないんですが、共通なUIに関してはDataBinding的要素で言うと、Baseなレイアウトを定義してIncludeしていこうよ、っていうことと、共通で参照するデータはBaseなkotlin dataクラスを定義して継承していくと良いのではないかなと思っています。メリットとしては以下です。

  • 同じようなコードが増えるのを防げる
  • デザイナーが作る時と似たデザイン設計になると思う
    • フィードUIをシンボル化する的な?Sketchでは共通パーツは1箇所変更すると他の参照しているところも一気に変わってくれるらしい
  • 無謀なデザインが来た時に逃げ道になる
    • 通化できないことによるデザイン的な違和感を指摘できたり、「これ対応するとこういう風に工数増えるので...」といった説得材料になる
  • コードそのものが仕様になる
    • 仕様的なズレが要件定義の段階で指摘できる可能性が広がる、というのと、フィードUIはItemクラスとlayoutのxmlさえ見れば理解できるようになると思っています。

ざっくりLayoutとkotlin dataクラスの例を書いてみたいと思います。 今回はBaseなデザインに 画像タイトルメッセージユーザー名 の入ったUIを想定していて、そこに何か追加のパーツが必要なデザインが降りてきた場合を考えてみます。

Base Class

// item_base_feed.mxl
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="viewModel"
            type="com.sample.feed.BaseFeedDataViewModel" />
    </data>

    // megeタグとか使っても良いのかも
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <ImageView
            android:id="@+id/image"
            android:layout_width="50dp"
            android:layout_height="50dp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:loadImage="@{viewModel.image}"/>       
       
        <TextView
            android:id="@+id/title"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/space_6dp"
            android:ellipsize="end"
            android:maxLines="1"
            android:text="@{viewModel.title}"
            app:layout_constraintEnd_toEndOf="@+id/parent"
            app:layout_constraintStart_toEndOf="@+id/image"
            app:layout_constraintTop_toTopOf="parent"/>

        <TextView
            android:id="@+id/message"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:ellipsize="end"
            android:maxLines="1"
            android:text="@{viewModel.message}"
            app:layout_constraintEnd_toEndOf="@+id/parent"
            app:layout_constraintStart_toEndOf="@+id/image"
            app:layout_constraintTop_toBottomOf="@+id/title"/>

        <FrameLayout
            android:id="@+id/item_layout_container"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:foreground="@drawable/ripple"
            android:onClick="@{viewmodel::onClickItemLayoutContainer}"
            app:layout_constraintBottom_toBottomOf="@+id/image"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/user_name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="@dimen/space_4dp"
            android:singleLine="true"
            android:ellipsize="end"
            app:clickListener="@{viewmodel::onClickUserName}"
            android:text="@{viewmodel.userName}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/image"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>
abstract class BaseFeedDataViewModel {

    val title = ObservableField<String>()
    val message = ObservableField<String>()
    val userName = ObservableField<String>()
    val image = ObservableField<String>()
    
    open fun setData(feed: BaseFeed) {
        title.set(feed.title)
        message.set(feed.message)
        userName.set(feed.userName)
        
        profileUrl.set(feed.image)
        profileUrl.notifyChange()
    }

    open fun clearData() {
        profileUrl.set(null)
    }

    abstract fun onClickItemLayoutContainer(@Suppress("UNUSED_PARAMETER") view: View)

    abstract fun onClickUserName(@Suppress("UNUSED_PARAMETER") view: View)
}

Extend Class

// item_extend_feed.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:bind="http://schemas.android.com/apk/res-auto">

    <data>

        <variable
            name="viewModel"
            type="com.sample.feed.ExtendFeedDataViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <!-- 何か新たに定義したい情報を増やせばOK -->

        <include
            android:id="@+id/base_feed_view"
            layout="@layout/item_base_feed"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/◯◯◯"
            bind:viewmodel="@{viewModel}" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>
class ExtendFeedDataViewModel(private val listener: OnFeedClickActionListener) : BaseFeedDataViewModel() {

    // 何か新たに定義したい情報を増やせばOK
    val ◯◯◯ = ObservableField<String>()

    private lateinit var currentExtendFeed: ExtendFeed

    override fun setData(feed: ExtendFeed) {
        super.setData(feed)
        currentExtendFeed = feed

        // 新たに定義したい値のセット
    }

    override fun clearData() {
        super.clearData()
    }

    override fun onClickItemLayoutContainer(@Suppress("UNUSED_PARAMETER") view: View) {
        listener.onFeedClick(currentExtendFeed)
    }

    override fun onClickUserName(@Suppress("UNUSED_PARAMETER") view: View) {
        listener.onUserClick(currentExtendFeed)
    }
}

今回の記事ではGroupieは本題ではなかったのでDataBindingに処理を寄せた形でサンプルを書いてみています。 このスタイルのポイントは主に3点あるかなと思います。

  • BaseFeedDataViewModelのonClickのメソッドはabstractメソッドになっていること
    • 拡張したViewModelの方で特定の処理を書ける
    • DataBindingのonClickは問題なく反応してくれます
  • item_extend_feedからbindで渡しているExtendFeedDataViewModelはBaseFeedDataViewModelを継承しているので問題なく渡せるということ
    • こちらもDataBindingのバインド処理はうまく機能してくれます
  • 新しいフィードUIが要件として登場しても少ないコードと正確さで実現できる柔軟性がありそう(だと思ってます)

GroupieのBindableItemのonBindメソッド内で処理を書いても似たようなことは実現できそうですね! クリックリスナーはGroupieだとonBindメソッドが呼ばれるたびにセットしてしまったりするのはこちらのコードだと防げそうではありますが、そこまで徹底しなくても良いのではとも思う今日この頃。はやく梅雨明けないかな。