RxJava,Kotlin,Databindingでイケてる入力フォームをスッキリ実装する

はじめまして。Pairs事業部の栗村貴尚(@t-kurimura)です。

主に、Pairs JapanのAndroid版の機能開発をしたり、スクラムマスターとして開発チームのアウトプットを最大化するために四苦八苦したりしております。これまでの記事でお気づきになった方もいらっしゃるかもしれませんが、実は、弊社のスクラムマスターのほとんどがネイティブアプリの開発がバックグランドだったりします。

さて、eureka Native Engineer Advent Calendar 2017 7日目の記事は、二川さんの「Pairs Android JPにおけるさがす画面のレイアウトの実現方法について」に続き、Androidネタです。

Pairs Japanは、Facebookログインに加えて最近SMSによるログインができるようになりました。それに際して、Androidでは、登録画面の改善を行い、ユーザーの入力状況に応じてUIが変化するように実装しました。その経験からRxJava,Kotlin,Databindingを利用した入力によってUIが変化するフォームの実装例をお伝えしたいと思います。

今回はの実装パターンは、いわゆるMVP,MVVM,Fluxといわれる設計パターンにとらわれすぎない、ミニマムな形で実現してます。

この記事でわかること

  • Pairs JapanのAndroid版の設計やKotlin, RxJava, Databindingの利用状況
  • Databindingを利用した双方向バインディング
  • RxJavaのcombinelatestの実用例
  • RxJavaのBehaviorSubjectの実用例
  • Kotlinのカスタムセッターの実用例
  • 操作によって同期的に他のUIが変わるViewの実装方法

Pairs JapanのAndroid版の開発状況

まずはじめに、この実装の背景となるPairs JapanのAndroid版の設計と、Kotlin、RxJava、Databindingといった言語、ライブラリの利用状況をご紹介したいと思います。

設計

Pairs JapanのAndroid版ではレイヤードアーキテクチャを利用しており、大きくPresentation層・Domain層・Infra層の3つに別れています。

レイヤー名 概要
Infra層 API通信を行うClient、データベースのと疎通するDAO(database access objectの略)、これら2つのデータを統合するRepositoryで構成されています。
Domain層 Infra層から得られるデータを元に必要な判定を行うビジネスロジックを有しています。
View層 ActivityやFragmentなどのViewとPresenterで構成され、ユーザーの入力や画面の変更を処理しています。

これに加えて通知バッジの管理やPush通知による変化など、1つのアクションをいくつかの画面で利用したい部分にはFluxを利用しています。Fluxについての詳しい解説は、「Androidにおける状態管理をスマートに実装するためにFluxを採用した話」が参考になると思います。

Kotlinの利用状況

Pairs JapanのAndroid版では、JetBrain製のKotlinをプロダクションコードに採用し始めておよそ1年が経過しました。機能開発やリファクタリングと並行しながらJavaをKotlinに置き換えています。新たなクラスや画面を作るときには必ずと言っていいほどKotlinを使っています。

僕自身、Kotlinの気持ちよさの虜になっています。気持ちよさの理由は、「PairsでKotlinを採用した5つの理由」の記事をぜひご参照ください!

機能開発を頻繁に行う部分やメインの画面はKotlinで書かれており、2017/12現在のJavaとKotlinの構成比は、おおよそ6: 4です。

RxJavaの利用状況

Presentation層・Domain層・Infra層の全てのレイヤーでRxJavaを使っています。Infra層、Domain層は、ほぼすべての処理がRxJavaを利用して処理されています。最近では、今回お伝えするようなUIの部分でのRxJavaの利用頻度も増えてきています。

また、来年3月に迫るサポート終了に向けRxJava1.0から2.0への移行も並行して行っており、共存させながら、低レイヤー側から順次置換を進めています。この記事ではRxJava2.0を利用しています。

Databindingの利用状況

Pairs JapanのAndroid版ではMVPアーキテクチャを採用しているため、特別Databindingとの相性が良いわけはありません。そのため、KotterknifeなどでViewのバインディングを行っている部分も少なくありません。

ただ、PresenterをXMLに渡すことで、ActivityやFragmentなどのViewが必要最小限のコードになり可読性が上がることやこれからお伝えするようなViewとデータの紐付けの実現がより容易になるので、比較的新しい部分や開発頻度が多い部分はDatabindingを積極的に利用しています。

実現する入力フォーム

さて、ここからやっと本題です。

今回は、ニックネーム、メールアドレス入力、性別選択、利用規約同意のチェックボックス、登録ボタンからなるよくある登録画面の入力フォームを実装します。

これらの入力項目にはそれぞれの条件に従ったバリデーションがあり、その入力状況に応じて各フォームが変化します。また全てのフォームで条件がクリアされていれば、登録ボタンの文字列と背景色が変化します。

登録ボタンが、押せるようにになるために満たす条件

  • ニックネームが、入力されていて、2文字以上10文字以下で入力されていること
  • 性別が、男性女性どちらかで入力されていること
  • 生年月日が、入力されていて、現在18歳以上であること

デモ

実装例

Activity, Layout XMLとフォームの入力状態を管理するFormPropertiesというクラスの3つで実装しています。それぞれの実装例をご紹介します。

Activity

    class FormActivity : AppCompatActivity() {

        private val compositeDisposable = CompositeDisposable()

        val properties = FormProperties()

        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            val binding = DataBindingUtil.setContentView<ActivityFormBinding>(this, R.layout.activity_form)
            binding.activity = this

            compositeDisposable.add(properties.getValidationObservable())
        }

        fun onGenderClicked() {
            val items = arrayOf("男性", "女性")
            AlertDialog.Builder(this)
                    .setTitle("性別を選択してください")
                    .setItems(items, { _, which -> properties.isMan = (which == 0) })
                    .show()
        }

        fun onBirthdayClicked() {
            DatePickerDialog(
                    this,
                    DatePickerDialog.OnDateSetListener({ _, y, m, d -> properties.birthday = Calendar.getInstance().apply { set(y, m, d) } }),
                    2000, 0, 1).show()
        }

        fun register() {
            startActivity(RegistrationCompleteActivity.createIntent(this))
            finish()
        }

        override fun onDestroy() {
            compositeDisposable.dispose()
            super.onDestroy()
        }
    }

FormProperties

    class FormProperties {

      enum class Gender { MAN, WOMAN, NOT_SET }

      // 各フォームの値が流れる
      private val nicknameSubject = BehaviorSubject.create<String>()
      private val genderSubject = BehaviorSubject.create<Gender>()
      private val birthdaySubject = BehaviorSubject.create<Calendar>()
      private val agreementSubject = BehaviorSubject.create<Boolean>()

      // 各フォームに表示する値が流れる
      val genderValueField: ObservableField<Boolean> = ObservableField()
      val birthdayValueField: ObservableField<String> = ObservableField()

      // 各フォームに入力された値が条件を満たしているかどうかの値が流れる
      val isValidNickNameField: ObservableField<Boolean> = ObservableField()
      val isValidGenderField: ObservableField<Boolean> = ObservableField()
      val isValidBirthdayField: ObservableField<Boolean> = ObservableField()
      val canRegister: ObservableField<Boolean> = ObservableField()

      init {
        birthdaySubject.onNext(Calendar.getInstance())
        genderSubject.onNext(Gender.NOT_SET)
        nicknameSubject.onNext("")
        agreementSubject.onNext(false)
      }

      var nickname: String = ""
        set(value) {
          field = value
          nicknameSubject.onNext(value)
        }

      var birthday : Calendar? = null
        set(value) {
          field = value
          value?.let {
            birthdayValueField.set(SimpleDateFormat("yyyy/MM/dd", Locale.JAPAN).format(value.time))
            birthdaySubject.onNext(value)
          }
        }

      var isMan: Boolean? = null
        set(value) {
          field = value
          value?.let {
            genderValueField.set(isMan)
            genderSubject.onNext(if(isMan == true) Gender.MAN else Gender.WOMAN)
          }
        }

      private var isAgreed: Boolean = false
      fun onCheckedChanged(v: View) {
        isAgreed = (v as CheckBox).isChecked
        agreementSubject.onNext(isAgreed)
      }

      private val nicknameValidationObservable = nicknameSubject.map {
        it.isNotEmpty() && it.length >= 2 && it.length <= 10 // ニックネームが2文字以上10文字以下
      }.doOnNext {
        isValidNickNameField.set(it)
      }

      private val birthdayValidationObservable = birthdaySubject.map {
        it.apply { add(Calendar.YEAR, 18 ) } < Calendar.getInstance() // 18歳以上
      }.doOnNext {
        isValidBirthdayField.set(it)
      }

      private val genderValidationObservable = genderSubject.map {
        it != Gender.NOT_SET // 男女どちらかが設定されている
      }.doOnNext {
        isValidGenderField.set(it)
      }

      fun getValidationObservable(): Disposable {
        return Observable
          .combineLatest(
            nicknameValidationObservable,
            birthdayValidationObservable,
            genderValidationObservable,
            agreementSubject,
            Function4<Boolean, Boolean, Boolean, Boolean, Boolean> {
              isValidName, isValidDob, isValidGender, isAgreed -> isValidName && isValidDob && isValidGender && isAgreed
            })
          .subscribe({ canRegister.set(it) },{ Log.e("error", it.message) })
      }
    }

Layout XML

わかりやすくするために色指定やmargin,paddingといった配置や色などに関するプロパティは省略しています。

    <layout xmlns:android="http://schemas.android.com/apk/res/android">

        <data>
            <import type="android.view.View" />
            <variable
                name="activity"
                type="com.sample.formsample.FormActivity" />
        </data>

        <LinearLayout
            android:layout_width="200dp"
            android:layout_height="wrap_content"
            android:orientation="vertical">

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="ニックネーム" />

              <! -- ニックネーム入力 --> 
            <EditText
                android:layout_width="match_parent"
                android:layout_height="20dp"
                android:background="@null"
                android:inputType="text"
                android:maxEms="15"
                android:maxLines="1"
                android:minLines="1"
                android:text="@={activity.properties.nickname}" />

            <View
                android:layout_width="match_parent"
                android:layout_height="1dp"
                android:background="@{activity.properties.isValidNickNameField ? @android:color/holo_green_dark : @android:color/darker_gray}" />

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="OK"
                android:textColor="@android:color/holo_green_dark"
                android:visibility="@{activity.properties.isValidNickNameField ? View.VISIBLE : View.INVISIBLE}" />

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="性別" />

            <!-- 「男性」or 「女性」の表示 -->
            <TextView
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:onClick="@{() -> activity.onGenderClicked()}"
                android:text="@{activity.properties.genderValueField == null ? @string/empty  : (activity.properties.genderValueField ? @string/male : @string/female)}" />

            <View
                android:layout_width="match_parent"
                android:layout_height="1dp"
                android:background="@{activity.properties.isValidGenderField ? @android:color/holo_green_dark : @android:color/darker_gray}" />

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="OK"
                android:textColor="@android:color/holo_green_dark"
                android:visibility="@{activity.properties.isValidGenderField ? View.VISIBLE : View.INVISIBLE}" />

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="生年月日" />

        <!-- [19XX/XX/XX]とゆう生年月日の表示 -->
            <TextView
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:onClick="@{() -> activity.onBirthdayClicked()}"
                android:text="@={activity.properties.birthdayValueField}" />

            <View
                android:layout_width="match_parent"
                android:layout_height="1dp"
                android:background="@{activity.properties.isValidBirthdayField ? @android:color/holo_green_dark : @android:color/darker_gray}" />

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="OK"
                android:textColor="@android:color/holo_green_dark"
                android:visibility="@{activity.properties.isValidBirthdayField ? View.VISIBLE : View.INVISIBLE}" />

                    <! -- 利用規約同意のチェックボックス --> 
            <CheckBox
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:onClick="@{(v) -> activity.properties.onCheckedChanged(v)}"
                android:text="利用規約に同意します" />

                    <! -- 利用規約同意のチェックボックス --> 
            <Button
                android:layout_width="match_parent"
                android:layout_height="53dp"
                android:background="@{activity.properties.canRegister ? @android:color/holo_green_dark : @android:color/darker_gray}"
                android:clickable="@{activity.properties.canRegister ? true : false}"
                android:enabled="@{activity.properties.canRegister ? true : false}"
                android:onClick="@{() -> activity.register()}"
                android:text="登録" />

        </LinearLayout>    
    </layout>

解説

さて、お気づきの方もいらっしゃるかと思いますが、MVVMといわれるViewModelの形に非常に近いです。今回のようなViewとModelが非常に密に結合するパターンはMVVMのモデルが適していると思います。

ただ、Propertiesという閉じたクラスを実装することで、MVVMに近い形を取り入れながらも、MVPなど他の設計でも採用しやすいように実装しています。ここからはこの実装の値の流れ方の全体像とポイントとなる箇所についてピックアップして解説します。

値の流れ方

大まかな値の流れ方が以下の図です。

BehaviorSubject

BehaviorSubjectは、最後にonNextで流れた値がキャッシュされています。subscribeすると、直前にonNextで流された値が1つ返ってきます。複数回発生するイベントをonNextで流し続けて、特定のタイミングで直前の値を利用したい今回の様なユースケースで非常に有用です。
この実装の中では、特定のニックネーム、性別などのフォームの値が変更される度にonNextに流しています。それぞれのBehaviorSubjectの中では、最後に入力された値が保持されるようになっています。

種類の似たSubjectで、PublisherSubjectというものがあります。こちらは、値を保持せず流れたら直ぐsubscribeの中に通知されます。従って、subscribeされる前に値を流してもその値を使うことは出来ないという違いがあります。

        val behaviorSubject = BehaviorSubject.create<Int>()
        val publisherSubject = PublishSubject.create<String>()
        behaviorSubject.onNext(1) // <--- point 1
        behaviorSubject.onNext(2) // <--- point 2
        publisherSubject.onNext("A") // <--- point 3
        publisherSubject.onNext("B") // <--- point 4

        behaviorSubject.subscribe {
            Log.v("behaviorSubject", it.toString())
            // >> "2"  (subscribeした瞬間)
            // >> "3"  (point 5 が処理された瞬間)
        }
        publisherSubject.subscribe {
            Log.v("publisherSubject", it)
            // >> "C" (point 6 が処理された瞬間)
        }

        behaviorSubject.onNext(3) // <--- point 5
        publisherSubject.onNext("C") // <--- point 6

こちらが、BehaviorSubjectに関する公式のドキュメントです。もし英語が苦手でも、マーブルダイアグラムと言われる図解や実装例も載っているので参考になると思います。

Observable#combinelatest

combinelatestは、Observaleのオペレーターの1つです。2つ以上のストリームとそれぞれのストリームから流れて来た複数の値を一つにする関数を引数として渡します。それぞれのストリームの最後の値を合体させます。

        val observable1 = Observable.just(1, 2, 3, 4, 5)
        val observable2 = Observable.just(1)

        Observable.combineLatest(
                observable1,
                observable2,
                BiFunction<Int, Int, String> { t1, t2 -> "arg1: $t1, arg2: $t2" })
                .subscribe {
                    Log.v("combineLatest", it)
                    // >> "arg1: 1, arg2: 1"
                    // >> "arg1: 1, arg2: 2"
                    // >> "arg1: 1, arg2: 3"
                    // >> "arg1: 1, arg2: 4"
                    // >> "arg1: 1, arg2: 5"
                }

いくつかのストリームの値を全て同時に利用したい場合に便利です。

今回のフォームの実装では登録ボタンは、ニックネーム、性別、生年月日、チェックボックスといった全てのフォームの状態をストリームで監視し、全てがTRUEになっているかどうかをcombineLatestの中で判定し、その結果がsubscribeに伝わるようにしています。

Kotlinのカスタムセッター

Kotlinのプロパティはカスタムセッターというセッターを定義することが出来ます。Kotlinでは、ほとんどの場合getterやsetterが定義されません。getter,setterがあるフィールドは、外部からアクセスできるプロパティと同じという考え方です。

しかし、それでは、setter,getterがないことによって、値が格納されたときに値を保持させる以外に別の操作をさせるということができません。そこで活用されるのがカスタムセッター(やカスタムゲッター)です。

以下は、Kotlinのカスタムセッターを簡易的にJavaで表現しています。

    public class User {

        private String nickname;

        private Integer nicknameLength = 0;

        public void setNickname(String nickname) {
            this.nickname = nickname
            nicknameLength = nickname.length(); // この部分がカスタムセッターで実現できるもの
        }
    }

フォームの実装では、それぞれ var nickname: Stringvar birthday: Calendar?… といったプロパティをFormPropertiesの中に持っています。その値が更新された瞬間に、その値が正当かどうかを判定するために上述したBehaviorSubjectに流します。そこでカスタムセッターを利用しています。

      var nickname: String = ""
        set(value) {
          field = value
          nicknameSubject.onNext(value) // nicknameSubjectに流す
        }

nicknameSubjectに流すと、それを監視しているnicknameValidationObservableが空文字ではないか?文字数は正当か?といった判定を行い「OK」という文字を表示させています。

カスタムセッターに関するKotlinの公式のドキュメントは日本語で書かれていて分かりやすいのでぜひ読んでみてください。

双方向Binding

最後にViewと値のバインディングについてです。カスタムセッターの中で*nicknameValidationObservable*が空文字ではないか?文字数は正当か?といった判定を行い「OK」という文字を表示させています。とご紹介しましたが、「OK」という文字をどのようにして表示させているのでしょうか?

この実装のなかでは、FormPropertiesの中でObservableFieldというプロパティを持っています。名前に”Observale”とついているとついRxJavaを連想しがちかと思いますが、ObservableFieldは、AndoridのDatabidingのライブラリのなかのクラスです。

ObservaleFieldを持っているレイアウトのDatabindingで生成されたクラスは、ObservalbeFieldに値をセットすると、その値をもつプロパティの値を更新したり、式の場合は再評価を行ったりして、Viewの更新を行います。

     val isValidGenderField: ObservableField<Boolean> = ObservableField()
     isValidGenderField.set(true) // 値を更新する

上のようにKotlinコード側から値をsetするとXMLファイルで定義した式が再評価されます。

    <!-- このViewが表示される -->
    android:visibility="@{activity.properties.isValidGenderField ? View.VISIBLE : View.INVISIBLE}" 

さて、これはKotlinからViewへ変更を通知する一方向のBindingでした。双方向Bindingは、Viewへの通知に加え、ユーザーが何らかの操作できるViewを変更したときにその通知がKotlin側のコードに通知されるというものです。

      var nickname: String = ""
        set(value) {
          field = value
          nicknameSubject.onNext(value)
        }
        <EditText
            android:layout_width="match_parent"
            android:layout_height="20dp"
            android:background="@null"
            android:inputType="text"
            android:maxEms="15"
            android:maxLines="1"
            android:minLines="1"
            android:text="@={activity.properties.nickname}" />

この2つは、どちらが更新されても、もう一方にその変更が適用されます。ユーザーがEditTextから値の入力をしたときは、Kotlin側のnicknameのプロパティのカスタムセッターが呼ばれ、保持する値が更新される同時にnicknameSubjectに値が流されます。値が流されることでバリデーションが走りOKが出たり出なかったりするといった処理の流れです。

おわりに

今回は、Pairsで実際に利用している入力フォームの実装方法の全体といくつかのポイントをお伝えしました。

入力状態によって、UIが変わるとユーザーは決定ボタンを押す前に未入力だったことに気付くことができ、UX向上に寄与する一因になると思います。一方で、コードが複雑化しそうで避けてしまいそうですが、想像より簡潔だったのではないでしょうか。

そして、今回は特定の設計に乗せずにご紹介しました。設計はコードの氾濫を防ぎ、安全性を高めるため非常に大事思います。ただ、一方で、様々なアーキテクチャのなかで必要最低限の実装で、他のアーキテクチャの良いところを部分的に取り入れられるようにすることも大事だと思っています。

この記事でご紹介した実装や情報が、一部だけでもお役に立てれば幸いです。

明日は、山中さん の記事です。お楽しみに!

追記 (2017/12/08)

@amay077 さんにRxProperty でイケてる入力フォームをもっとスッキリ実装するをご紹介頂きました!たしかにもっとスッキリしているので、ぜひこちらも合わせてご覧ください!

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

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

Recommend

エウレカのエンジニア向け勉強会への取り組みについて

【応用編】Elasticsearchの検索クエリを使いこなそう