- 投稿日:2020-07-20T05:46:45+09:00
Custom View 探求記(TextView 継承編 その4)
前回のあらすじ
前回は、custom view に状態保存の仕組みを追加しました。
今回の課題
今回は、Data Binding をサポートしようと思います。
実装1
◆ build.gradle
viewModels()
を使いたかったので追加。android { kotlinOptions { jvmTarget = '1.8' } } dependencies { def activity_version = "1.1.0" implementation "androidx.activity:activity-ktx:$activity_version" }◆ レイアウトファイル
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
ViewModelpackage 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 された時のデータ保存・復元をしていない)SampleActivity.ktpackage 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
変更点は、デバッグ出力を追加しただけです。
UnixEpochTextView.ktpackage 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 }実行
◆ 画面
☆ 起動直後
☆ ボタン押下
☆ 画面回転
☆ Activity 破棄&復元
◆ ログ
▼ 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
を実行すれば View に閉じた復元は機能しなくなるが、手間がかかるし見落としのミスも生じる可能性がある。- そもそも、データの初期値を、レイアウトファイルと view model の両方で持つということ自体が設計上の問題に思える。
☆ 対策
そう考えると、以下のように割り切ってしまったほうが良いのかもしれません。
- view の全状態(transitive なものは除く)が view model に存在すると考えたほうが開発も保守もラクチンなはず。
- アプリ開発プロジェクトにて、application architecture レベルの決定として、view の復元は View Model 由来のものだけとし、view 単体でのデータの保存・復元は行わないと割り切っちゃえばいいんじゃね? ただし、attr 指定はレイアウトファイルのプレビューで必要となるので残しておいたほうが良さそう。純粋にレイアウトの都合での attr 指定であればレイアウトファイル上に書くほうが責務的に正しいし、動的に変更されないので保存・復元する必要もない。推移的でない動的な変更はすべて ViewModel 経由で行う。
実装2
ということで、引き続き、View Model 側でのデータ保存・復元と、custom view 側でのデータ保存・復元を削除していきます。
◆ UnixEpochTextView
UnixEpochTextView.ktpackage 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 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 を用意しています。
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 の保存と復元用のコードが追加されています。
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" } }実行
◆ 画面
☆ 起動直後
☆ ボタン押下
☆ 画面回転
☆ Activity 破棄&復元
◆ ログ
▼ 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
第1回: Custom View 探求記(TextView 継承編 その1)
第2回: Custom View 探求記(TextView 継承編 その2)
第3回: Custom View 探求記(TextView 継承編 その3)
第4回: Custom View 探求記(TextView 継承編 その4)
- 投稿日:2020-07-20T02:32:30+09:00
大学生が1週間でFlutterアプリを学んでリリースした過程(3日目)
こんばんはシオンです。
一週間でアプリをリリースすると決めてから3日目。
昨日今後の予定を立てて、あとは前に進むだけです。残りの工程としては、デザイン(今日)、プログラミング(4〜6日目)、リリース(7日目)。今日は予定通りAdobeXDを使ってデザインを完成させます。さっそくやっていきましょう。
目次
■アプリのデザインを完成させる
■デザインを作る
■まとめ
■アプリのデザインを完成させる
まずは、AdobeXDを開きます。
デザインを作っていく上で、軸にするデバイス(iphone〜とかのやつ)ですが今回はiphone8で作っていきます。理由は今使われているデバイスの中で一番小さいからです。(昔のiphoneSEとかは考えません!)ちなみに立ち上げた瞬間はこんな感じ。
さて、デザインを作っていきますがデザインを作るために昨日考えたことを思い出します。
<アプリのデザイン> ┣ デザインを作るソフト ━ AdobeXD ┣ デザインソフトを使う知識 ━ すでに持っているからOK ┗ ソフトでデザインを作るために必要な要素 ┣ アプリのコンセプト ┳ 何をする? ━ 愚痴を書き込んで消化する。 ━ 消化して次に進むためのアプリ ━ だから灰色、黒などはNGでも赤、黄色もテンションが違う。 ┃ ┣ 誰が使う? ━ 愚痴ある人、OL、20代の女性向け。 ━ 女性らしい色合いが良さそう。ピンク、紫 ┃ ┣ いつどこで使う? ━ 愚痴が溜まった時、他にいう人がいない時。 ━ すぐに書き込みたいからトップ画面に愚痴書き込み画面にしたい。 ┃ ┗ なぜ使う? ━ 吐き出して乗り越えるため。周りに言いたくないことを言う。言ってはいけない悩みを言う。 ━ 秘密、乗り越える勇気づけ、優しい、落ち着く ━ 紫色、緑色を使って秘密かつ落ち着く配色が良さそう。 ┣ アプリの機能 ┳ 愚痴を書き込む ━ 書き込むフォーム、題名とかあったほうがいいか? あと客観的に自分の状況を見るためにその愚痴がどのくらい嫌かの愚痴レベルをつけると良さそう ━ 題名、愚痴の内容2つを書き込むフォーム。愚痴レベル評価ボタン。 ┃ ┣ 消化する ━ 愚痴を消す。消化、乗り越えるだから、書き込み完了ボタン → 「愚痴を乗り越えて次に進みますか?」 → 「では今から何をしますか?」 みたいな感じで次の行動につなげる表示を出す。 ┃ ┗ 消化した数を表示する。 ━ 乗り越えた愚痴の数を表示する。これまでの積み上げ、一つの勇気になるか? ━ 画面の右上に表示。余裕があれば別画面に表示してメッセージとかをつける。 ┗ アプリの制作期間 ━ 一週間。 ━ Flutterの勉強をしながらになるから1~3画面に収める。なるほど、なるほど即席にしてはなかなかいいですね。昨日の自分。
デザインを完成させるために必要なことを考えます。
デザインを作るためには
・アプリのコンセプト
・使う色
・画面数
・画面ごとの機能
この辺があれば作っていけそうです。一つ一つ決めていきます。<アプリのコンセプト>
まず、アプリのコンセプトとしては昨日を参考にしましょう。
昨日考えたことを一言でまとめると
「ユーザーが愚痴を吐き出して前に進むことを後押しをするアプリ」
という感じでしょうか。ぱっと見良さそうですね。このコンセプトから使う色を決めていきます。
<配色を決める>
このコンセプトの中でフォーカスする点としては、愚痴を吐き出す。前に進む後押し。この二つでしょうか。
愚痴を吐き出す = 秘密、落ち着き = 紫、緑、茶色
前に進む後押し = 勇気付ける、背中を押す = 黄色
こんな感じでしょうか。もう時間もないのでこれでいきましょう。
ちなみに色のイメージとかはぐぐれば出てきます。こちらを参考にしました。<画面数と画面毎の機能>
これを決めるためにはまず、実装する機能を決定しましょう。
実装する機能としては
①愚痴を記入する機能
②愚痴を消す機能
③背中を後押しする機能①と②はフォームを置いてボタンをつければ良さそうです。
③を考えます。
背中を押すためには昨日の考えから行くと
・次に何をするかを決める機能
・乗り越えた愚痴の数を表示する機能この二つをつければ良さそうです。つまり今回作るアプリは
①愚痴を記入する
↓
②愚痴を消す
↓
③次に何をするかを決める
↓
④乗り越えた数を表示するというアプリになりそうです。
ここから画面数と画面毎の機能を決定していきます。①と②の昨日は一緒の画面で行けそうです。
③の機能で1画面。
④は次に何をするかを決める画面で+1の表示。合計数はスタート画面を設けてそこに表示しましょう。ということで、画面数と画面毎の機能としては
スタート画面: 乗り越えた数と愚痴を記入するページへのボタンを設置。
愚痴解消画面: 愚痴のタイトル、内容のフォームと消すボタンを設置。
乗り越え画面: 次の行動を記入するフォームと、ボタンを設置。できればメッセージとかあれば尚良し(時間次第)。以上の3画面を作れば良さそうです。
■デザインを作る
では、さっそくこの3画面のデザインを決めていきます。
あ、そういえば今XDのファイルを保存するところで名前を決めなければいけないことに気づきました。何がいいでしょうか?とりあえず、解消くんにしておこうと思います(あとで絶対に変えます。)
では、スタート画面から作っていきます。
タイトルとボタンと乗り越える数を配置していきます。そして色を調節していきます。うーんこのパーツだけだと結構物寂しい感じになってしまいました。(配置と配色の問題か?)これはだめですね。。。
これだと3つパーツあって何をどこをみたらいいのかわかりませんし、ダサいし。コンセプト的に乗り越えた数よりも愚痴を吐き出すがメインだからそっちを目立たせて、乗り越えた数はサブにします。なんかこねくり回している間によく分からなくなってきました。(これは良くなっているのだろうか)
ちなみにこのしたの花はサザンカといい、花言葉は「困難に打ち勝つ」だそうです。コンセプトにあってるなーと思いましたが、なんか花に頼っておしゃれ感出そうとしてるのが見え透いているのがダサいですね。てか、解消くんって何・・・今日デザインを完成させる予定だったのですが、だめです。全然出てきません。そもそも配色の設定が間違っているのかもしれません。
ちょっと日程的にキツキツですが明日デザインを決めてプログラミングに入ろうと思います。
これは雲行きが怪しくなってきましたね。。。■まとめ
最初から完成する気が全くしていなかったのですが、今日で1%から0.01%に完成する確率が下がった気がします。。
どうでもいいんですけど、なぜFlutterを学ぶために始めたのに、3日目にしてコードが書けていないのだろうか。
いやでもリンカーンさんが準備は大事って言ってたしな。。
明日の自分に期待しましょう!
- 投稿日:2020-07-20T01:36:37+09:00
Custom View 探求記(TextView 継承編 その3)
前回のあらずじ
前回は、custom view の情報をレイアウトファイルから設定できるようにし、その振る舞いをレイアウトエディタ上から確認しました。
前回の不満点
- 画面回転などにより値がリセットされてしまう。
今回の課題
- custom view に状態保存機能を実装する。
関連する関数
- protected open fun onSaveInstanceState(): Parcelable?
- 内部状態表現を生成する
- protected open fun onRestoreInstanceState(state: Parcelable!): Unit
- onSaveInstanceState() で生成された情報から状態を復元する
- open fun setSaveEnabled(enabled: Boolean): Unit
- view の状態保存を有効にするかどうかを設定する(idは必須)
- 【私見】外部からの設定を前提としているため、custom view 内部から呼び出すものではないと思う。
現状確認
▼ SHOW CURRENT TIME ボタン押下直後
ボタン押下時の現在時刻が表示されている。
▼ 画面回転直後
セットされた時刻がリセットされてしまっている。
変更実装
◆ build.gradle
API 23 で DateTimeFormatter を利用したかったので、 desugaring を利用するために以下を追加しました。
android { compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9' }◆ Custom View クラス
- SimpleDateFormat 等のクラスを DateTimeFormatter 等の Java 8 new Time APIs に置き換えました。
- onSaveInstanceState(), onRestoreInstanceState(Parcelable) をオーバーライドすることにより状態保存の仕組みを導入しました。
UnixEpochTextView.ktpackage 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) override fun onRestoreInstanceState(thisStateParcel: Parcelable) = super.onRestoreInstanceState(restoreThisInstanceStateAndGetSuperInstanceState(thisStateParcel, this::restoreThisState)) private fun restoreThisState(thisInstanceState: ThisInstanceInstanceState) { unixEpoch = thisInstanceState.unixEpoch } @Parcelize private data class ThisInstanceInstanceState( override val superInstanceState: Parcelable?, val unixEpoch: Long ) : ThisInstanceStateBase, Parcelable }◆ Viewの状態保存に関する共通コード
interface ThisInstanceStateBase { val superInstanceState: Parcelable? } fun <T : ThisInstanceStateBase> restoreThisInstanceStateAndGetSuperInstanceState(thisInstanceState: Parcelable, restoreThisInstanceState: (T) -> Unit): Parcelable? = @Suppress("UNCHECKED_CAST") (thisInstanceState as T).run { restoreThisInstanceState(this) superInstanceState }◆ SampleActivity
unixEpochTextView.setUnixEpoch(System.currentTimeMillis())
がunixEpochTextView.unixEpoch = System.currentTimeMillis()
に変わっただけです。SampleActivity.ktpackage com.objectfanatics.chrono10.ex_cv import android.os.Bundle import android.widget.Button import androidx.appcompat.app.AppCompatActivity import com.objectfanatics.chrono10.R class SampleActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.sample_activity) val unixEpochTextView = findViewById<UnixEpochTextView>(R.id.unix_epoch_text_view) val showCurrentTimeButton = findViewById<Button>(R.id.show_current_time_button) showCurrentTimeButton.setOnClickListener { unixEpochTextView.unixEpoch = System.currentTimeMillis() } } }考察
◆ 今回やったこと
今回は、UnixEpochTextView に状態保存の仕組みを追加してみました。
◆ 問題点
- SDK 等から提供される多くの View は、レイアウトファイル内部での属性指定を、databinding でもサポートしていることが多いが、現在の UnixEpochTextView ではサポートしていない。
◆ 次回
次回は、UnixEpochTextView を Data Binding に対応させてみようと思います。
Links
第1回: Custom View 探求記(TextView 継承編 その1)
第2回: Custom View 探求記(TextView 継承編 その2)
第3回: Custom View 探求記(TextView 継承編 その3)
第4回: Custom View 探求記(TextView 継承編 その4)
- 投稿日:2020-07-20T01:13:28+09:00
robolectricでZip解凍とCSV解析のテストコードを試作しましょう!
最近Zipファイル中身のCSVファイルの解析とテストコード書きのタスクを完成して、こちらでちょっど整理しますね。
1.Csv読み込みのメソッドを作成する
LoginHelper.kt// 例: taro,abc (ユーザ名,パスワード) open fun readCsvFile(input: InputStream, expactedName: String): String? { try { val parser = CSVParser.parse(input, Charset.defaultCharset(), CSVFormat.RFC4180.withIgnoreEmptyLines() .withHeader("ユーザ名", "パスワード") .withFirstRecordAsHeader() .withIgnoreSurroundingSpaces()) parser.filter { it.size() > 1 }.forEach { val userName = it.get(0) val password = it.get(1) if (userName.equals(expactedName)) { return password } } parser.close() } catch (e: Exception) { e.printStackTrace() return null } return null }2.Zipファイル解凍メソッドを作成する
LoginHelper.ktfun getPasswordFromZipFile(file: File, expactedName: String): String? { try { val zipFile = ZipFile(file) if (zipFile.isEncrypted) { zipFile.setPassword("123456") } val fileHeader = zipFile.getFileHeader("userInfo.csv") val input = zipFile.getInputStream(fileHeader) return readCsvFile(input, expactedName) } catch (e: Exception) { e.printStackTrace() return null } }ここまで、機能実装コート完成した!
( ̄▽ ̄)( ̄▽ ̄)( ̄▽ ̄) 私は実装コードとテストコードの分割線です ( ̄▽ ̄)( ̄▽ ̄)( ̄▽ ̄)
3.Csvファイル解析テストコード
CsvFileParseTest.kt@RunWith(ParameterizedRobolectricTestRunner::class) @PrepareForTest(System::class, LoginHelper::class) class CsvFileParserTest( private var userName: String, private var result: String?, private val dataFileName: String? ) { companion object { @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") @JvmStatic fun data() = listOf( arrayOf("taro", "aaaaccc", "user_info_data.csv"), //正常ケース arrayOf("sanro", "8888\\r77\\n55", "user_info_data.csv"),//パスワードに改行が含まれる arrayOf("sichiro", "", "user_info_data.csv"), //Csvファイル読む時、関連のパスワードを見つけない場合 arrayOf("", "", "user_info_data.csv"), //受け取る名前がからになる arrayOf("hachiro", null, null), //Csvファイルnullになる arrayOf("kyuro", null, "user_info_empty.csv") //Csvファイルは空です ) } internal val context: Context get() = RuntimeEnvironment.application @Test fun readCsvAccountInfoTest() { val password: String? if (dataFileName != null) { //テストのため、 user_info_data.csvテストファイルがassetsフォルダに入れて、読み込み val am = context.assets val input = am.open(dataFileName) password = LoginHelper().readCsvAccountInfo(input, userName) } else { password = LoginHelper().readCsvAccountInfo(ByteArrayInputStream(byteArrayOf()), userName) } Assert.assertEquals(result, password) } }4.Zipファイル解凍テストコード
ZipFileParseTest.kt@RunWith(ParameterizedRobolectricTestRunner::class) @PrepareForTest(System::class, LoginHelper::class) class ZipFileParseTest( private var userName: String, private var result: String?, private var isEncrypt: Boolean, private var fileNameInZipFile: String, private var zipFilePath: String?, private var is0byte: Boolean, private var zipPassword: String, private var isReadCsvFileCalled: Boolean ) { companion object { @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") @JvmStatic fun data() = listOf( arrayOf("taro", "password1", true, "user_info.csv", Environment.getExternalStorageDirectory()?.path + "/user_info.zip", false, "password", true), //正常ケース arrayOf("taro", "password2", false, "user_info.csv", Environment.getExternalStorageDirectory()?.path + "/user_info.zip", false, "password", true), //暗号化されていない arrayOf("taro", null, false, "user_info.txt", Environment.getExternalStorageDirectory()?.path + "/user_info.zip", false, "password", false), //ファイルタイプ合ってない arrayOf("taro", null, false, "user_info.csv", "888888888.zip", false, "password", false), //ファイル名合ってない arrayOf("taro", null, false, "user_info.csv", Environment.getExternalStorageDirectory()?.path + "/user_info.zip", true, "password", false), //ZIPファイルが壊れる arrayOf("taro", null, false, "user_info.csv", Environment.getExternalStorageDirectory()?.path + "/user_info.zip", false, "123", true) //Zipパスワードを正しく入力されない ) } internal val context: Context get() = RuntimeEnvironment.application @Test fun getPasswordFromZipFileParseTest() { val csv = "タイトル1,タイトル2\r\njiro,password1\r\nsanro,password2\r\nsiro,password3" val csvInputStream = ByteArrayInputStream(csv.toByteArray()); val externalStorageDirectory = Environment.getExternalStorageDirectory() val zipPath = File(externalStorageDirectory, "user_info.zip") val commonParameters = ZipParameters().apply { isSourceExternalStream = true encryptionMethod = Zip4jConstants.ENC_METHOD_STANDARD isEncryptFiles = isEncrypt password = zipPassword.toCharArray() fileNameInZip = fileNameInZipFile } if (is0byte) { zipPath.createNewFile() //0byteのzipファイルを作成する } else { val zipFile = ZipFile(zipFilePath) zipFile.addStream(csvInputStream, commonParameters) } val helper = TempCheckCsvFile(result) val password = helper.getPasswordFromZipFile(zipPath, userName) Assert.assertEquals(result, password) Assert.assertEquals(isReadCsvFileCalled, helper.isTempCheckCsvFileCalled) //CSVの処理を略しても、呼ばれるかを検証します。 if (isReadCsvFileCalled) { Assert.assertEquals(csv, IOUtils.toString(helper.readCsvAccountInfoStream, "UTF-8")) Assert.assertEquals(userName, helper.name) } } //主にZip解凍テストするため、こちらはCsv解析の処理を継承とオーバライドで略します。 class TempCheckCsvFile(val result: String?) : LoginHelper() { internal var isTempCheckCsvFileCalled = false internal lateinit var readCsvAccountInfoStream: InputStream internal var name: String? = null override fun readCsvAccountInfo(input: InputStream, userName: String): String? { isTempCheckCsvFileCalled = true readCsvAccountInfoStream = input name = userName return result } } }追加:
元々Csv解析の処理を呼ばれるかの検証に対して、mockito-kotlin の
verify(helper, times(1)).readCsvAccountInfo(any(), any())
を使ってみたいですが、java.lang.IllegalArgumentException: Parameter specified as non-null is null
: のエラーが出ますが、 この記事Parameter-specified-as-non-null-is-nullも解決できなさそうですので、継承とオーバライドでTempCheckCsvFile
のクラスを作成して、テストします。