20200720のAndroidに関する記事は4件です。

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

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 された時のデータ保存・復元をしていない)

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

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

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
}

実行

◆ 画面

☆ 起動直後

☆ ボタン押下

☆ 画面回転

☆ 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.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 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)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

大学生が1週間でFlutterアプリを学んでリリースした過程(3日目)

こんばんはシオンです。

一週間でアプリをリリースすると決めてから3日目。
昨日今後の予定を立てて、あとは前に進むだけです。残りの工程としては、デザイン(今日)、プログラミング(4〜6日目)、リリース(7日目)。

今日は予定通りAdobeXDを使ってデザインを完成させます。さっそくやっていきましょう。

目次

■アプリのデザインを完成させる
■デザインを作る
■まとめ

■アプリのデザインを完成させる

まずは、AdobeXDを開きます。
デザインを作っていく上で、軸にするデバイス(iphone〜とかのやつ)ですが今回はiphone8で作っていきます。理由は今使われているデバイスの中で一番小さいからです。(昔のiphoneSEとかは考えません!)

ちなみに立ち上げた瞬間はこんな感じ。

1.png

さて、デザインを作っていきますがデザインを作るために昨日考えたことを思い出します。

<アプリのデザイン>
 ┣ デザインを作るソフト ━ AdobeXD
 ┣ デザインソフトを使う知識 ━ すでに持っているからOK
 ┗ ソフトでデザインを作るために必要な要素
       ┣ アプリのコンセプト ┳ 何をする? ━ 愚痴を書き込んで消化する。 ━ 消化して次に進むためのアプリ ━ だから灰色、黒などはNGでも赤、黄色もテンションが違う。
       ┃              ┣ 誰が使う? ━ 愚痴ある人、OL、20代の女性向け。 ━ 女性らしい色合いが良さそう。ピンク、紫
       ┃              ┣ いつどこで使う? ━ 愚痴が溜まった時、他にいう人がいない時。 ━ すぐに書き込みたいからトップ画面に愚痴書き込み画面にしたい。
       ┃              ┗ なぜ使う? ━ 吐き出して乗り越えるため。周りに言いたくないことを言う。言ってはいけない悩みを言う。 ━ 秘密、乗り越える勇気づけ、優しい、落ち着く ━ 紫色、緑色を使って秘密かつ落ち着く配色が良さそう。
       ┣ アプリの機能 ┳ 愚痴を書き込む ━ 書き込むフォーム、題名とかあったほうがいいか? あと客観的に自分の状況を見るためにその愚痴がどのくらい嫌かの愚痴レベルをつけると良さそう ━ 題名、愚痴の内容2つを書き込むフォーム。愚痴レベル評価ボタン。
       ┃           ┣ 消化する ━ 愚痴を消す。消化、乗り越えるだから、書き込み完了ボタン → 「愚痴を乗り越えて次に進みますか?」 → 「では今から何をしますか?」 みたいな感じで次の行動につなげる表示を出す。
       ┃           ┗ 消化した数を表示する。 ━ 乗り越えた愚痴の数を表示する。これまでの積み上げ、一つの勇気になるか? ━ 画面の右上に表示。余裕があれば別画面に表示してメッセージとかをつける。
       ┗ アプリの制作期間 ━ 一週間。 ━ Flutterの勉強をしながらになるから1~3画面に収める。

なるほど、なるほど即席にしてはなかなかいいですね。昨日の自分。

デザインを完成させるために必要なことを考えます。
デザインを作るためには
・アプリのコンセプト
・使う色
・画面数
・画面ごとの機能
この辺があれば作っていけそうです。一つ一つ決めていきます。

<アプリのコンセプト>
まず、アプリのコンセプトとしては昨日を参考にしましょう。
昨日考えたことを一言でまとめると
「ユーザーが愚痴を吐き出して前に進むことを後押しをするアプリ」
という感じでしょうか。ぱっと見良さそうですね。

このコンセプトから使う色を決めていきます。

<配色を決める>
このコンセプトの中でフォーカスする点としては、愚痴を吐き出す。前に進む後押し。この二つでしょうか。
愚痴を吐き出す = 秘密、落ち着き = 紫、緑、茶色
前に進む後押し = 勇気付ける、背中を押す = 黄色
こんな感じでしょうか。もう時間もないのでこれでいきましょう。
ちなみに色のイメージとかはぐぐれば出てきます。こちらを参考にしました。

<画面数と画面毎の機能>
これを決めるためにはまず、実装する機能を決定しましょう。
実装する機能としては
①愚痴を記入する機能
②愚痴を消す機能
③背中を後押しする機能

①と②はフォームを置いてボタンをつければ良さそうです。
③を考えます。
背中を押すためには昨日の考えから行くと
・次に何をするかを決める機能
・乗り越えた愚痴の数を表示する機能

この二つをつければ良さそうです。つまり今回作るアプリは

①愚痴を記入する

②愚痴を消す

③次に何をするかを決める

④乗り越えた数を表示する

というアプリになりそうです。
ここから画面数と画面毎の機能を決定していきます。

①と②の昨日は一緒の画面で行けそうです。
③の機能で1画面。
④は次に何をするかを決める画面で+1の表示。合計数はスタート画面を設けてそこに表示しましょう。

ということで、画面数と画面毎の機能としては
スタート画面: 乗り越えた数と愚痴を記入するページへのボタンを設置。
愚痴解消画面: 愚痴のタイトル、内容のフォームと消すボタンを設置。
乗り越え画面: 次の行動を記入するフォームと、ボタンを設置。できればメッセージとかあれば尚良し(時間次第)。

以上の3画面を作れば良さそうです。

■デザインを作る

では、さっそくこの3画面のデザインを決めていきます。

2.png

あ、そういえば今XDのファイルを保存するところで名前を決めなければいけないことに気づきました。何がいいでしょうか?とりあえず、解消くんにしておこうと思います(あとで絶対に変えます。)

では、スタート画面から作っていきます。
タイトルとボタンと乗り越える数を配置していきます。そして色を調節していきます。うーんこのパーツだけだと結構物寂しい感じになってしまいました。(配置と配色の問題か?)

3.png

これはだめですね。。。
これだと3つパーツあって何をどこをみたらいいのかわかりませんし、ダサいし。コンセプト的に乗り越えた数よりも愚痴を吐き出すがメインだからそっちを目立たせて、乗り越えた数はサブにします。

4.png

なんかこねくり回している間によく分からなくなってきました。(これは良くなっているのだろうか)
ちなみにこのしたの花はサザンカといい、花言葉は「困難に打ち勝つ」だそうです。コンセプトにあってるなーと思いましたが、なんか花に頼っておしゃれ感出そうとしてるのが見え透いているのがダサいですね。てか、解消くんって何・・・

今日デザインを完成させる予定だったのですが、だめです。全然出てきません。そもそも配色の設定が間違っているのかもしれません。

ちょっと日程的にキツキツですが明日デザインを決めてプログラミングに入ろうと思います。
これは雲行きが怪しくなってきましたね。。。

■まとめ

最初から完成する気が全くしていなかったのですが、今日で1%から0.01%に完成する確率が下がった気がします。。

どうでもいいんですけど、なぜFlutterを学ぶために始めたのに、3日目にしてコードが書けていないのだろうか。
いやでもリンカーンさんが準備は大事って言ってたしな。。
明日の自分に期待しましょう!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Custom View 探求記(TextView 継承編 その3)

前回のあらずじ

前回は、custom view の情報をレイアウトファイルから設定できるようにし、その振る舞いをレイアウトエディタ上から確認しました。

前回の不満点

  • 画面回転などにより値がリセットされてしまう。

今回の課題

  • 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 クラス

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)

    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.kt
package 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)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.kt
    fun 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
        }
    }
}

ここまで、終わり!
OJBk.jpeg

追加:

元々Csv解析の処理を呼ばれるかの検証に対して、mockito-kotlinverify(helper, times(1)).readCsvAccountInfo(any(), any())を使ってみたいですが、java.lang.IllegalArgumentException: Parameter specified as non-null is null: のエラーが出ますが、 この記事Parameter-specified-as-non-null-is-nullも解決できなさそうですので、継承とオーバライドでTempCheckCsvFileのクラスを作成して、テストします。

  

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む