Custom View 探求記（TextView 継承編 その４）

# 前回のあらすじ
前回は、custom view に状態保存の仕組みを追加しました。

# 今回の課題
今回は、Data Binding をサポートしようと思います。

# 実装１

## ◆ build.gradle
`viewModels()` を使いたかったので追加。

```
android {
    kotlinOptions {
        jvmTarget = '1.8'
    }
}

dependencies {
    def activity_version = "1.1.0"
    implementation "androidx.activity:activity-ktx:$activity_version"
}
```

## ◆ レイアウトファイル
<img width="240px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/1c788862-ab89-4da2-63ba-5ffe1c427488.png">

```xml:layout
<?xml version="1.0" encoding="utf-8"?>
<layout>
    <data>
        <variable
            name="viewModel"
            type="com.objectfanatics.chrono10.ex_cv.SampleActivityViewModel" />
    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <com.objectfanatics.chrono10.ex_cv.UnixEpochTextView
            android:id="@+id/unix_epoch_text_view"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:textSize="30sp"
            app:layout_constraintBottom_toTopOf="@id/show_current_time_button"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_chainStyle="packed"
            app:unixEpoch="@{viewModel.unixEpoch}" />
        <Button
            android:id="@+id/show_current_time_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:text="show current time"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            android:onClick="@{viewModel::onShowCurrentTimeButtonClick}"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/unix_epoch_text_view" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
```

## ◆ ViewModel
```kotlin:ViewModel
package com.objectfanatics.chrono10.ex_cv

import android.view.View
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel

class SampleActivityViewModel : ViewModel() {
    val unixEpoch: MutableLiveData<Long> = MutableLiveData(0)

    fun onShowCurrentTimeButtonClick(v: View) = update()

    private fun update() {
        unixEpoch.postValue(System.currentTimeMillis().apply {
            println("Log: SampleActivityViewModel#update($this)")
        })
    }
}
```

## ◆ Activity
Data Binding 対応中。
※ 意図的に不完全な実装にしてあります。(Activity が kill された時のデータ保存・復元をしていない)

```kotlin:SampleActivity.kt
package com.objectfanatics.chrono10.ex_cv

import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import com.objectfanatics.chrono10.R
import com.objectfanatics.chrono10.databinding.SampleActivityBinding

class SampleActivity : AppCompatActivity() {
    private val viewModel: SampleActivityViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        println("Log: SampleActivity#onCreate(unixEpoch=${viewModel.unixEpoch.value})")
        super.onCreate(savedInstanceState)

        println("Log: setContentView()")
        val binding = DataBindingUtil.setContentView<SampleActivityBinding>(this, R.layout.sample_activity)

        println("Log: data binding")
        binding.viewModel = viewModel
        binding.lifecycleOwner = this
    }
}
```

## ◆ Custom View
変更点は、デバッグ出力を追加しただけです。

```kotlin:UnixEpochTextView.kt
package com.objectfanatics.chrono10.ex_cv

import android.content.Context
import android.os.Parcelable
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import com.objectfanatics.chrono10.R
import com.objectfanatics.infra.android.view.ThisInstanceStateBase
import com.objectfanatics.infra.android.view.getLongAttr
import com.objectfanatics.infra.android.view.restoreThisInstanceStateAndGetSuperInstanceState
import kotlinx.android.parcel.Parcelize
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.*

class UnixEpochTextView : AppCompatTextView {
    constructor(context: Context?) : super(context) {
        initAttrs(null)
    }

    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
        initAttrs(attrs)
    }

    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        initAttrs(attrs)
    }

    private fun initAttrs(attrs: AttributeSet?) {
        getLongAttr(attrs, R.styleable.UnixEpochTextView, R.styleable.UnixEpochTextView_unixEpoch, 0, this::unixEpoch.setter)
    }

    var unixEpoch: Long
        get() = Instant.from(unixEpochFormatter.parse(text)).toEpochMilli()
        set(unixEpoch) {
            text = unixEpoch.unixEpochString
        }

    companion object {
        private val unixEpochFormatter =
            DateTimeFormatter.ofPattern("yyyy-MM-dd\nHH:mm:ss.SSS", Locale.US).withZone(ZoneId.of("Asia/Tokyo"))

        private val Long.unixEpochString: String
            get() = unixEpochFormatter.format(Instant.ofEpochMilli(this))
    }

    override fun onSaveInstanceState(): Parcelable =
        ThisInstanceInstanceState(
            super.onSaveInstanceState(),
            unixEpoch.apply { println("Log: UnixEpochTextView.onSaveInstanceState(): unixEpoch = $this") }
        )

    override fun onRestoreInstanceState(thisStateParcel: Parcelable) =
        super.onRestoreInstanceState(restoreThisInstanceStateAndGetSuperInstanceState(thisStateParcel, this::restoreThisState))

    private fun restoreThisState(thisInstanceState: ThisInstanceInstanceState) {
        unixEpoch = thisInstanceState.unixEpoch
            .apply { println("Log: UnixEpochTextView.restoreThisState(): unixEpoch = $this") }
    }

    @Parcelize
    private data class ThisInstanceInstanceState(
        override val superInstanceState: Parcelable?,
        val unixEpoch: Long
    ) : ThisInstanceStateBase, Parcelable
}
```

# 実行

## ◆ 画面
### ☆ 起動直後
<img width="240px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/ce22bc59-87e1-36c7-7761-c3ddb4ef3928.png">

### ☆ ボタン押下
<img width="240px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/6bb6d678-e40e-3bf3-6931-6d0c3f53ecab.png">

### ☆ 画面回転
<img width="480px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/6e67de17-a166-0311-2892-e716431a2f4f.png">

### ☆ Activity 破棄＆復元
<img width="480px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/6e67de17-a166-0311-2892-e716431a2f4f.png">

## ◆ ログ
```
▼ Activity の onCreate() にて ViewModel が新規作成される。
I: Log: SampleActivity#onCreate(unixEpoch=0)

I: Log: setContentView()
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 0 ← from attr

I: Log: data binding
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 0 ← from View Model

▼ ボタンを押下して現在時刻をセット
I: Log: SampleActivityViewModel#update(1595189145418)
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 1595189145418

▼ 画面回転
I: Log: UnixEpochTextView.onSaveInstanceState(): unixEpoch = 1595189145418
I: Log: SampleActivity#onCreate(unixEpoch=1595189145418)

I: Log: setContentView()
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 0               ← from attr

I: Log: data binding
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 1595189145418   ← from View Model
I: Log: UnixEpochTextView.restoreThisState(): unixEpoch = 1595189145418
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 1595189145418   ← from custom view

▼ Activity の kill
I: Log: UnixEpochTextView.onSaveInstanceState(): unixEpoch = 1595189145418

▼ Activity の kill からの復元
I: Log: SampleActivity#onCreate(unixEpoch=0)

I: Log: setContentView()
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 0               ← from attr

I: Log: data binding
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 0               ← View Model のデータ保存と復元を忘れているため View Model の unixEpoch は 0.
I: Log: UnixEpochTextView.restoreThisState(): unixEpoch = 1595189145418
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 1595189145418   ← custom view により、見た目のデータだけ復元している。
```

## ◆ 考察

### ☆ 振る舞い
ログから確認できる振る舞いは、以下のようになります。

- 現状のコードは、Activity が kill された場合のデータ復元コードがない状態である。
- レイアウトファイルでの属性指定は data binding より前に実行されるため、値が指定されていないという扱いになる。
- 画面回転時は、configuration change 後に、view model の値が view 上に復元された後に onRestoreInstanceState() にて View に閉じて状態が復元される。
- Activity が kill から復活した際は、現状のコードでは Activity 側ではデータの保存と復元を行っていないため、View Model のデータは初期化され unixEpoch = 0 になる。
- View Model の値が View 上に反映した後に、View 側でデータ復元が行われるため、View Model 側で 0 に初期化されていても、View 側単独でデータが復元される。

### ☆ 保守性
これは結構保守に難があると思われます。

- 今回のように、表示と View Model の内容が乖離するような場合、状況の把握が難しい。
- view model と view 自体がデータの保存と復元を行っているが、往々にして二重管理は bug prone。
- 無駄に端末のストレージや保存復元のコストが増える。
- data binding 時に `binding.unixEpochTextView.isSaveEnabled = false` や `binding.unixEpochTextView.isSaveFromParentEnabled = false` を実行すれば View に閉じた復元は機能しなくなるが、手間がかかるし見落としのミスも生じる可能性がある。
- そもそも、データの初期値を、レイアウトファイルと view model の両方で持つということ自体が設計上の問題に思える。

### ☆ 対策
そう考えると、以下のように割り切ってしまったほうが良いのかもしれません。

- view の全状態(transitive なものは除く)が view model に存在すると考えたほうが開発も保守もラクチンなはず。
- アプリ開発プロジェクトにて、application architecture レベルの決定として、view の復元は View Model 由来のものだけとし、view 単体でのデータの保存・復元は行わないと割り切っちゃえばいいんじゃね？ ただし、attr 指定はレイアウトファイルのプレビューで必要となるので残しておいたほうが良さそう。純粋にレイアウトの都合での attr 指定であればレイアウトファイル上に書くほうが責務的に正しいし、動的に変更されないので保存・復元する必要もない。推移的でない動的な変更はすべて ViewModel 経由で行う。

# 実装２
ということで、引き続き、View Model 側でのデータ保存・復元と、custom view 側でのデータ保存・復元を削除していきます。

## ◆ UnixEpochTextView
```kotlin:UnixEpochTextView.kt
package com.objectfanatics.chrono10.ex_cv

import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import com.objectfanatics.chrono10.R
import com.objectfanatics.infra.android.view.getLongAttr
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.*

class UnixEpochTextView : AppCompatTextView {
    constructor(context: Context?) : super(context) {
        initAttrs(null)
    }

    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
        initAttrs(attrs)
    }

    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        initAttrs(attrs)
    }

    private fun initAttrs(attrs: AttributeSet?) {
        getLongAttr(attrs, R.styleable.UnixEpochTextView, R.styleable.UnixEpochTextView_unixEpoch, 0, this::unixEpoch.setter)
    }

    var unixEpoch: Long
        get() = Instant.from(unixEpochFormatter.parse(text)).toEpochMilli()
        set(unixEpoch) {
            println("Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = $unixEpoch")
            text = unixEpoch.unixEpochString
        }

    companion object {
        private val unixEpochFormatter =
            DateTimeFormatter.ofPattern("yyyy-MM-dd\nHH:mm:ss.SSS", Locale.US).withZone(ZoneId.of("Asia/Tokyo"))

        private val Long.unixEpochString: String
            get() = unixEpochFormatter.format(Instant.ofEpochMilli(this))
    }
}
```

## ◆ レイアウトファイル
```xml
<?xml version="1.0" encoding="utf-8"?>
<layout>
    <data>
        <variable
            name="viewModel"
            type="com.objectfanatics.chrono10.ex_cv.SampleActivityViewModel" />
    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <com.objectfanatics.chrono10.ex_cv.UnixEpochTextView
            android:id="@+id/unix_epoch_text_view"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:textSize="30sp"
            app:layout_constraintBottom_toTopOf="@id/show_current_time_button"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_chainStyle="packed"
            app:unixEpoch="@{viewModel.unixEpoch}" />
        <Button
            android:id="@+id/show_current_time_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:text="show current time"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            android:onClick="@{viewModel::onShowCurrentTimeButtonClick}"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/unix_epoch_text_view" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
```

## ◆ ViewModel
状態保存のために State クラスの定義と state の getter/setter を用意しています。

```kotlin
package com.objectfanatics.chrono10.ex_cv

import android.os.Parcelable
import android.view.View
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import kotlinx.android.parcel.Parcelize

class SampleActivityViewModel : ViewModel() {
    val unixEpoch: MutableLiveData<Long> = MutableLiveData(0)

    fun onShowCurrentTimeButtonClick(v: View) = update()

    private fun update() {
        unixEpoch.postValue(System.currentTimeMillis().apply {
            println("Log: SampleActivityViewModel#update($this)")
        })
    }

    var state: State
        set(value) {
            unixEpoch.postValue(value.unixEpoch)
        }
        get() = State(unixEpoch.value!!)

    @Parcelize
    class State(val unixEpoch: Long) : Parcelable
}
```

## ◆ Activity
State の保存と復元用のコードが追加されています。

```kotlin
package com.objectfanatics.chrono10.ex_cv

import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import com.objectfanatics.chrono10.R
import com.objectfanatics.chrono10.databinding.SampleActivityBinding
import com.objectfanatics.chrono10.ex_cv.SampleActivityViewModel.State

class SampleActivity : AppCompatActivity() {
    private val viewModel: SampleActivityViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        println("Log: SampleActivity#onCreate(): unixEpoch=${viewModel.unixEpoch.value}")
        super.onCreate(savedInstanceState)

        savedInstanceState?.getParcelable<State>(KEY_STATE)?.let { state ->
            println("Log: SampleActivity#onCreate(): restore from savedInstanceState: unixEpoch = ${state.unixEpoch}")
            viewModel.state = state
        }

        println("Log: data binding")
        val binding = DataBindingUtil.setContentView<SampleActivityBinding>(this, R.layout.sample_activity)
        binding.viewModel = viewModel
        binding.lifecycleOwner = this
    }

    override fun onSaveInstanceState(outState: Bundle) {
        println("Log: SampleActivity#onSaveInstanceState(): unixEpoch=${viewModel.unixEpoch.value}")
        outState.putParcelable(KEY_STATE, viewModel.state)
        super.onSaveInstanceState(outState)
    }

    companion object {
        private const val KEY_STATE = "KEY_STATE"
    }
}
```

# 実行

## ◆ 画面
### ☆ 起動直後
<img width="240px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/ce22bc59-87e1-36c7-7761-c3ddb4ef3928.png">

### ☆ ボタン押下
<img width="240px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/0f222a87-893b-7c70-0baf-c58b866a0a00.png">

### ☆ 画面回転
<img width="480px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/db6d3e5e-b2ad-413a-e6ef-0ee7eb4e1b52.png">

### ☆ Activity 破棄＆復元
<img width="480px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/db6d3e5e-b2ad-413a-e6ef-0ee7eb4e1b52.png">

## ◆ ログ
```
▼ Activity の onCreate() にて ViewModel が新規作成される。
I: Log: SampleActivity#onCreate(): unixEpoch=0

▼ data binding にて ViewModel の値がセットされる。
I: Log: data binding
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 0 ← from attr
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 0 ← from View Model

▼ ボタンを押下して現在時刻をセット
I: Log: SampleActivityViewModel#update(1595186552954)
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 1595186552954

▼ 画面回転
I: Log: SampleActivity#onSaveInstanceState(): unixEpoch=1595186552954
I: Log: SampleActivity#onCreate(): unixEpoch=1595186552954
I: Log: SampleActivity#onCreate(): restore from savedInstanceState: unixEpoch = 1595186552954
I: Log: data binding
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 0             ← from attr
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 1595186552954 ← from View Model

▼ Activity の kill
I: Log: SampleActivity#onSaveInstanceState(): unixEpoch=1595186552954

▼ Activity の kill からの復元
I: Log: SampleActivity#onCreate(): unixEpoch=0
I: Log: SampleActivity#onCreate(): restore from savedInstanceState: unixEpoch = 1595186552954

▼ Activity の kill からの復元
I: Log: data binding
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 0             ← from attr
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 0             ← from View Model ※復元値がセットされる前の状態でイベントが飛んでるっぽい。
I: Log: UnixEpochTextView.unixEpoch.setter: unixEpoch = 1595186552954 ← from View Model
--------------------------------------------------------------------------------------------------------------------------
```

## ◆ 考察

### ☆ 振る舞い
- データの保存と復元は、すべて View Model に基づいている。

### ☆ 保守性
- データの管理(推移的な物を除く)が View Model に閉じるので保守が楽。

# 考察
## ◆ 今回やったこと
- data binding を利用してみました。
- custom view 側でのデータ保存・復元を行わない方針にしました。

## ◆ 問題点
TextView を継承した custom view を作ることはできましたが、コードが全体的に混然一体となってスパゲッティ感を醸し出すようになったような気がします。

## ◆ 次回
ということで、次回からは、custom view の適切な構造について考えてみようと思います。

# Links
第１回： [Custom View 探求記（TextView 継承編 その１）](https://qiita.com/beyondseeker/items/612b6bcee2937c98aedb)
第２回： [Custom View 探求記（TextView 継承編 その２）](https://qiita.com/beyondseeker/items/119873801b4bb07d4d5c)
第３回： [Custom View 探求記（TextView 継承編 その３）](https://qiita.com/beyondseeker/items/e9e04a6fe96be9e65bda)
第４回： [Custom View 探求記（TextView 継承編 その４）](https://qiita.com/beyondseeker/items/64ca710fd408e559840d)← <font color="red">いまココ！</font>
第５回： [Custom View 探求記（DataBindingを使うべきか使わぬべきかそれが問題だ編 その１）](https://qiita.com/beyondseeker/items/92c7598e6c55f23b1018)