iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 19
0

上一篇連結Day18 MVVM專案-4

本篇會使用Day15 MVVM專案-2 Simple Fragment -3 DI建置好的fragment
以及創一個新的fragment來做互動

先新增一個activity

S05Activity.kt

    override fun onCreate(savedInstanceState: Bundle?) {
 val s02Fragment = Stage02Fragment.newInstance()
        val s05Fragment = S05Fragment.newInstance()

        supportFragmentManager.beginTransaction()
            .replace(R.id.fl_top, s02Fragment)
            .replace(R.id.fl_bottom, s05Fragment)
            .commit()
            }

目前階段畫面
https://ithelp.ithome.com.tw/upload/images/20191004/201202797jEPr9IUDH.png

然後目前直接沿用DI的專案會有BUG 我還在修正中 所以先把S02的專案還原回之前的寫法

Stage02Fragment.kt

class Stage02Fragment : Fragment() {

    private val createVMFactory: () -> ViewModelProvider.Factory by lazy {
        arguments?.getSerializable("createVMFactory") as? () -> ViewModelProvider.Factory
            ?: throw IllegalArgumentException("no createVMFactory for ${this::class.java.simpleName}")
    }
    val viewModel: Stage02ViewModel by lazy {
        ViewModelProviders.of(this.activity!!, createVMFactory()).get(Stage02ViewModel::class.java)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewModel.result.observe(this, Observer {
            lav_result.setAnimation(
                when (it) {
                    Stage02ViewModel.TryResult.FAILED -> R.raw.s02_failed
                    Stage02ViewModel.TryResult.SUCCESS -> R.raw.s02_succes
                    else -> throw IllegalStateException()
                }
            )
            lav_result.playAnimation()
        })

        viewModel.clearAction.observe(this, Observer {
            lav_result.cancelAnimation()
            lav_result.progress = 0.0f
        })
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? =
        DataBindingUtil.inflate<Stage02FragmentBinding>(
            inflater,
            R.layout.stage02_fragment,
            container,
            false
        ).also {
            it.setLifecycleOwner(this)
            it.viewModel = viewModel
        }.root

    companion object {

        @JvmStatic
        fun newInstance(createVMFactory: () -> ViewModelProvider.Factory = ::Stage02ViewModelFactory) =
            Stage02Fragment().apply {
                arguments = Bundle().apply {
                    putSerializable("createVMFactory", createVMFactory as Serializable)
                }
            }
    }

}

注意 除了還原外

 ViewModelProviders.of(this, createVMFactory()).get(Stage02ViewModel::class.java)

要改為

 ViewModelProviders.of(this.activity!!, createVMFactory()).get(Stage02ViewModel::class.java)

否則S5Fragment呼叫S02的viewModel時資料不會連動

單元測試也一樣記得改回去

S02FragmentTest.kt

@RunWith(AndroidJUnit4::class)
class S02FragmentTest {
    @get:Rule
    val rule = object : ActivityTestRule<FragmentTestActivity>(FragmentTestActivity::class.java) {}

    private fun justTrue() = true

    @Before
    fun setFragment() {

        // given
        val createVMFactory = {
            object : ViewModelProvider.Factory {
                @Suppress("UNCHECKED_CAST")
                override fun <T : ViewModel?> create(modelClass: Class<T>): T =
                    Stage02ViewModel(::justTrue).apply {
                        speedOfAnim.value = 10.0f
                    } as T
            }
        }

        rule.activity.replaceFragment(Stage02Fragment.newInstance(createVMFactory))
    }


    @Test
    fun score_should_increase_when_success() {
        // when
        Espresso.onView(withId(R.id.bt_try)).perform(ViewActions.click())
        Espresso.onView(isRoot())
            .perform(ViewAssertionsEx.waiting(1000)) // waiting to end animation

        // then
        val expected =
            String.format(rule.activity.resources.getString(R.string.stage02_score_format), 1)
        Espresso.onView(withId(R.id.tv_score)).check(matches(withText(expected)))
    }

    object ViewAssertionsEx {
        @JvmStatic
        fun waiting(milliSec: Long) = object : ViewAction {
            override fun getDescription(): String = "waiting $milliSec milli seconds"

            override fun getConstraints(): Matcher<View> = isDisplayed()

            override fun perform(uiController: UiController, view: View) =
                uiController.loopMainThreadForAtLeast(milliSec)
        }
    }

}

更新viewModel

S05ViewModel.kt

class S05ViewModel : ViewModel() {
    val logList: MutableLiveData<MutableList<String>> =
        MutableLiveData<MutableList<String>>().apply {
            value = mutableListOf()
        }
    val progress = MutableLiveData<Int>().apply { value = 0 }
    val animSpeed: LiveData<Int> = Transformations.map(progress) { it + 1 }

    val sdf = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.getDefault())

    fun add(line: String) {
        logList.value!!.add("${sdf.format(Date())}   $line")
    }

    fun clear() {
        logList.value = mutableListOf("clearAction")
    }
}

新增一個adapter

S05LogListAdapter.kt

class S05LogListAdapter : ListAdapter<String, S05LogListAdapter.VH>(getDiffCallback()) {
    class VH(v: View) : androidx.recyclerview.widget.RecyclerView.ViewHolder(v)

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        val v = LayoutInflater.from(parent.context).inflate(R.layout.listitem_s05, parent, false)
        return VH(v)
    }

    override fun onBindViewHolder(holder: VH, position: Int) {
        holder.itemView.tv_line.text = getItem(position)
    }

    companion object {
        @JvmStatic
        fun getDiffCallback() = object : DiffUtil.ItemCallback<String>() {
            override fun areItemsTheSame(oldItem: String, newItem: String): Boolean =
                oldItem === newItem

            override fun areContentsTheSame(oldItem: String, newItem: String): Boolean =
                oldItem == newItem
        }
    }

}

更新fragment

S05Fragment.kt

class S05Fragment : Fragment() {

    private val createVMFactory: () -> ViewModelProvider.Factory by lazy {
        @Suppress("UNCHECKED_CAST")
        arguments?.getSerializable("createVMFactory") as? () -> ViewModelProvider.Factory
            ?: throw IllegalArgumentException("no createVMFactory for ${this::class.java.simpleName}")
    }
    private val viewModel: S05ViewModel by lazy {
        ViewModelProviders.of(this).get(S05ViewModel::class.java)
    }
    private val adapter = S05LogListAdapter()
    val e02ViewModel: Stage02ViewModel by lazy {
        ViewModelProviders.of(this.activity!!, createVMFactory()).get(Stage02ViewModel::class.java)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewModel.logList.observe(this, Observer {
            adapter.submitList(it)
        })



        e02ViewModel.clearAction.observe(this, Observer {
            viewModel.clear()
        })

        fun <T : Any> createObserverForAddingLine(prefix: String): Observer<T> = Observer {
            viewModel.add("$prefix${it.toString()}")
            adapter.notifyItemInserted(adapter.itemCount - 1)
            rcv_log.scrollToPosition(adapter.itemCount - 1)
        }

        e02ViewModel.result.observe(this, createObserverForAddingLine("try result : "))
        e02ViewModel.score.observe(this, createObserverForAddingLine("score : "))

        val setSpeedObserver = createObserverForAddingLine<Int>("set speed  ✕ ")
        viewModel.animSpeed.observe(this, Observer {
            e02ViewModel.speedOfAnim.value = it!!.toFloat()
            setSpeedObserver.onChanged(it)
        })
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? = DataBindingUtil.inflate<S05FragmentBinding>(
        inflater,
        R.layout.s05_fragment,
        container,
        false
    ).also {
        it.setLifecycleOwner(this)
        it.viewModel = viewModel
    }.root

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        rcv_log.adapter = adapter
        rcv_log.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context)
    }

    companion object {
        @JvmStatic
        fun newInstance(createVMFactory: () -> ViewModelProvider.Factory = ::Stage02ViewModelFactory) =
            S05Fragment().apply {
                arguments = Bundle().apply {
                    putSerializable("createVMFactory", createVMFactory as Serializable)
                }
            }
    }

}

新增對應的xml

listitem_s05.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.AppCompatTextView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/tv_line"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:textColor="@android:color/darker_gray"
    android:textSize="12sp"
    tools:text="[00.00.00] line" />

s05_fragment.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="viewModel"
            type="com.ithome11.jetpackmvvmdemo.main.s05.S05ViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".main.s05.S05Activity">

        <androidx.appcompat.widget.AppCompatTextView
            android:id="@+id/tv_anim_speed"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:text="@{@string/stage05_animation_speed(viewModel.animSpeed)}"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <androidx.appcompat.widget.AppCompatSeekBar
            android:id="@+id/sb_speed"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="24dp"
            android:layout_marginLeft="24dp"
            android:layout_marginTop="8dp"
            android:layout_marginEnd="24dp"
            android:layout_marginRight="24dp"
            android:max="9"
            android:progress="@={viewModel.progress}"
            app:layout_constraintTop_toBottomOf="@id/tv_anim_speed" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rcv_log"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_marginTop="8dp"
            android:background="@android:color/black"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/sb_speed"
            tools:listitem="@layout/listitem_s05" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

更新字串
strings.xml

<resources>
   ...
    <string name="stage05_animation_speed">animation speed : ✕ %d</string>
</resources>

完成後畫面
https://ithelp.ithome.com.tw/upload/images/20191004/20120279w2GBjMlO66.png

最後新增整合測試

S05FragmentTest.kt

@RunWith(AndroidJUnit4::class)
class S05FragmentTest {
    @get:Rule
    val rule = ActivityTestRule<FragmentTestActivity>(FragmentTestActivity::class.java)

    lateinit var fragment: S05Fragment

    private fun justTrue() = true

    @Before
    fun setFragment() {

        // given
        val createVMFactory = {
            object : ViewModelProvider.Factory {
                @Suppress("UNCHECKED_CAST")
                override fun <T : ViewModel?> create(modelClass: Class<T>): T = Stage02ViewModel(::justTrue).apply {
                    speedOfAnim.value = 10.0f
                } as T
            }
        }
        UiThreadStatement.runOnUiThread {
            //            viewModel = E02ViewModel(::justTrue)
            fragment = S05Fragment.newInstance(createVMFactory)
            rule.activity.replaceFragment(fragment)
        }
    }

    @Test
    fun tryResult_should_add_list() {
        // when
        UiThreadStatement.runOnUiThread {
            fragment.e02ViewModel.tryResult()
            fragment.e02ViewModel.applyScore()
        }

        // then
        onView(withId(R.id.rcv_log)).check(ViewAssertionsEx.hasItemCountOfRecyclerView(4))
    }

    @Test
    fun clear_should_clear_list() {
        // when
        UiThreadStatement.runOnUiThread {
            fragment.e02ViewModel.tryResult()
            fragment.e02ViewModel.applyScore()
            fragment.e02ViewModel.clear()
        }

        // then
        onView(withId(R.id.rcv_log)).check(ViewAssertionsEx.hasItemCountOfRecyclerView(1))
    }

    @Test
    fun observing_animSpeed_works() {
        UiThreadStatement.runOnUiThread {
            fragment.e02ViewModel.speedOfAnim.observe(fragment, Observer {})
        }

        // given
        val givenProgress = 2

        // when
        onView(withId(R.id.sb_speed)).perform(ViewActionsEx.setProgress(givenProgress))

        // then
        Assert.assertEquals(3.0f, fragment.e02ViewModel.speedOfAnim.value)
    }

}

object ViewAssertionsEx {
    @JvmStatic
    fun isInvisible() = ViewAssertion { view, noViewFoundException ->
        if (noViewFoundException != null ) throw noViewFoundException
        Assert.assertEquals(View.INVISIBLE, view.visibility)
    }

    @JvmStatic
    fun hasItemCountOfRecyclerView(count: Int) = ViewAssertion { view, noViewFoundException ->
        if (noViewFoundException != null ) throw noViewFoundException
        val v = view as androidx.recyclerview.widget.RecyclerView
        Assert.assertEquals(count, v.adapter!!.itemCount)
    }
}

單元測試

E05ViewModelTest.kt

class E05ViewModelTest {
    @Rule
    @JvmField
    val rule = InstantTaskExecutorRule()

    @Test
    fun addAndClear() {
        val viewModel = E05ViewModel()

        // given
        val givenLine = "first"

        // when
        viewModel.add(givenLine)

        // then
        Assert.assertEquals(1, viewModel.logList.value!!.size)
        Assert.assertTrue(viewModel.logList.value!![0].contains(givenLine))

        // when
        viewModel.add(givenLine)
        viewModel.add(givenLine)
        viewModel.clear()

        // then
        Assert.assertEquals(1, viewModel.logList.value!!.size)
    }



    @Test
    fun animSpeed() {
        val viewModel = E05ViewModel()
        val lifecycle = LifecycleRegistry(mockk()).apply {
            handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
        }
        viewModel.animSpeed.observe({ lifecycle }) {}

        // given
        val givenProgress = 0

        // when
        viewModel.progress.value = givenProgress

        // then
        Assert.assertEquals(1, viewModel.animSpeed.value)
    }

}

solution
https://github.com/mars1120/jetpackMvvmDemo/tree/mvvm-05-Fragments


上一篇
Day18 MVVM專案-4 RequestPermission
下一篇
Day20 ROOM -1 (番外)
系列文
Android × CI/CD 如何用基本的MVVM專案實現 CI/CD 30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言