用 Kotlin 开发现代 Android 项目 Part 2

简评:继续第一部分的文章,作者在第二部分中使用的技术包括 MVVM,RxJava2.

5. MVVM 架构 + 存储库模式 + Android 管理封装器

关于 Android 世界的一点点架构知识

长时间以来,Android 开发者们在他们的项目中没有使用任何类型的架构。近三年以来,架构在 Android 开发者社区中被炒得天花乱坠。Activity 之神的时代已经过去了,Google 发布了 Android 架构蓝图仓库,提供了许多样例和说明来实现不同的架构方式。最终,在 Google IO 17 大会,他们介绍了 Android 架构组件,这些库的集合帮助我们编写更清晰的代码和更好的 app。你可以使用所有的组件,也可以使用其中的部分。但是,我觉得它们都挺有用的。接下来我们将使用这些组件。我会先解决这些问题,然后用这些组件和库来重构代码,看看这些库解决了哪些问题。

有两种主要的架构模式分离了 GUI 代码:

  • MVP
  • MVVM

很难说哪一种更好。你应该两种都尝试一下,然后再做出决定。我倾向于使用管理生命周期组件的 MVVM 模式,并且接下来我会介绍它。如果你没试过 MVP,在 Medium 上有大量很好的关于它的文章。

什么是 MVVM 模式?

MVVM 模式是一种架构模式。它代表 Model-View-ViewModel。我觉得这个名字会让开发者困扰。如果我是那个命名的人,我会称之为 View-ViewModel-Model,因为 ViewModel 是连接视图和模型的中间件。

  • View 是你的 Activity,Fragment 或者其他 Android 自定义视图的抽象。注意千万别和 Android View 混淆了。这个 View 应该是个哑巴,我们不能在 View 里写任何逻辑。视图不应该持有任何数据。它应该有一个 ViewModel 实例,所有 View 需要的数据应该从这个实例中获取。同时,View 应该观察这些数据,一旦 ViewModel 中的数据发生了变化,布局就会发生改变。View 有一个职责:不同的数据和状态下,布局会怎样显示。

  • ViewModel 是持有数据和逻辑类的一个抽象,负责什么时候获取数据,什么时候传递数据。ViewModel 保留当前的状态。同时,ViewModel 持有一个或多个 Model 实例,所有的数据都从这些实例中获取。它不应该知道这些数据是从数据库中获取的还是从远程服务器中获取的。此外,ViewModel 也不应该知道关于 View 的一切。而且,ViewModel 也不应该知道任何关于 Android 框架的事。

  • Model 是为 ViewModel 准备数据的抽象层。它是那些从远程服务器或者内存缓存或者本地数据库获取数据的类。这和那些 User,Car,Square 等等是不一样的。模型类知识一些保存数据的类。通常,它是仓库模式的一种实现,我们接下来将会讲到。Model 不该知道关于 ViewModel 的一切。

MVVM,如果正确实现,将是一种很好的分离代码的方式,这样也会让它更容易测试。它帮助我们遵循 SOLID 原则,因此我们的代码更容易维护。

现在我将写一个最简单的示例来解释它是怎样工作的。

首先,创建一个简单的Model来返回字符串:

class RepoModel {

    fun refreshData() : String {
        return "Some new data"
    }
}

通常,获取数据是异步的,所以我们必须等待一下。为了模拟这种情况,我把它改成下面这样:

class RepoModel {

    fun refreshData(onDataReadyCallback: OnDataReadyCallback) {
        Handler().postDelayed({ onDataReadyCallback.onDataReady("new data") },2000)
    }
}

interface OnDataReadyCallback {
    fun onDataReady(data : String)
}

首先,我创建了 OnDataReadyCallback 接口,它有个 onDataReady 函数。现在,我们的 refreshData 函数实现了 OnDataReadyCallback 。为了模拟等待,我使用了 Handler。2 秒后,OnDataReadyCallback 的实现将会调用 onDataReady 函数。

现在来创建我们的ViewModel

class MainViewModel {
    var repoModel: RepoModel = RepoModel()
    var text: String = ""
    var isLoading: Boolean = false
}

可以看到,有一个 RepoModel 的示例,即将展示的 text 以及保存当前状态的 isLoading 。现在,创建一个 refresh 函数,用来获取数据:

class MainViewModel {
    ...

    val onDataReadyCallback = object : OnDataReadyCallback {
        override fun onDataReady(data: String) {
            isLoading.set(false)
            text.set(data)
        }
    }

    fun refresh(){
        isLoading.set(true)
        repoModel.refreshData(onDataReadyCallback)
    }
}

refresh 函数调用了 RepoModel 中的 refreshData,传递了一个实现 OnDataReadyCallback 接口的实例。好,那么什么是对象呢?无论何时,当你想实现一些接口或者继承一些类而不用创建子类时,你都会使用对象声明。如果你想要使用匿名类呢?在这里,你需要使用 object 表达式:

class MainViewModel {
    var repoModel: RepoModel = RepoModel()
    var text: String = ""
    var isLoading: Boolean = false

    fun refresh() {
        repoModel.refreshData( object : OnDataReadyCallback {
        override fun onDataReady(data: String) {
            text = data
        })
    }
}

当我们调用 refresh,我们应该把 view 改成加载中的状态,并且一旦获取到数据,就把 isLoading 设置为 false。同时,我们应该把 text 改成ObservableField<String>,把 isLoading 改成 ObservableField<Boolean>ObservableField 是一个数据绑定库中的类,我们可以用它来创建一个可观察对象。它把对象包裹成可被观察的。

class MainViewModel {
    var repoModel: RepoModel = RepoModel()

    val text = ObservableField<String>()

    val isLoading = ObservableField<Boolean>()

    fun refresh(){
        isLoading.set(true)
        repoModel.refreshData(object : OnDataReadyCallback {
            override fun onDataReady(data: String) {
                isLoading.set(false)
                text.set(data)
            }
        })
    }
}

注意到我使用了 val 而不是 var,因为我们仅在字段里改变它的值,而不是字段本身。如果你想要初始化的话,应该这样:

val text = ObservableField("old data")
val isLoading = ObservableField(false)

现在改变我们的布局,让它可以观察 textisLoading 。首先,我们会绑定 MainViewModel 而不是 Repository:

<data>
    <variable
        name="viewModel"
        type="me.fleka.modernandroidapp.MainViewModel" />
</data>

然后:

  • 让 TextView 观察 MainViewModel 中的 text

  • 添加一个 ProgressBar,当 isLoading 为 true 时才会显示

  • 添加一个 Button,在 onClick 中调用 refresh 函数,仅当 isLoading 为 false 时才能点击

...

        <TextView
            android:id="@+id/repository_name"
            android:text="@{viewModel.text}"
            ...
            />

        ...
        <ProgressBar
            android:id="@+id/loading"
            android:visibility="@{viewModel.isLoading ? View.VISIBLE : View.GONE}"
            ...
            />

        <Button
            android:id="@+id/refresh_button"
            android:onClick="@{() -> viewModel.refresh()}"
            android:clickable="@{viewModel.isLoading ? false : true}"
            />
...

如果现在运行的话,你会得到一个错误,因为如果没有导入 View 的话,View.VISIBLEView.GONE 不能使用。所以,我们应该导入:

<data>
        <import type="android.view.View"/>

        <variable
            name="viewModel"
            type="me.fleka.modernandroidapp.MainViewModel" />
</data>

好,布局完成了。现在我们来完成绑定。如我们所说的 View 应该持有 ViewModel 的实例:

class MainActivity : AppCompatActivity() {

    lateinit var binding: ActivityMainBinding

    var mainViewModel = MainViewModel()

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

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.viewModel = mainViewModel
        binding.executePendingBindings()

    }
}

最终,我们的运行效果:


可以看到 old data 变成了 new data

这就是最简单的 MVVM 示例。

还有一个问题,让我们来旋转手机:


new data 又变成了 old data。怎么可能?看下 Activity 的生命周期:

一旦你旋转屏幕,新的 Activity 实例就会创建,onCreate() 方法会被调用。现在,看下我们的 Activity:

class MainActivity : AppCompatActivity() {

    lateinit var binding: ActivityMainBinding

    var mainViewModel = MainViewModel()

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

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.viewModel = mainViewModel
        binding.executePendingBindings()

    }
}

如你所见,一旦创建了一个新的 Activity 实例,MainViewModel 的实例也被创建了。如果每次重新创建的 MainActivity 都有一个相同的
MainViewModel 实例会不会好点?

隆重推出生命周期感知组件

因为许多的开发者面临这个问题,Android 框架团队的开发者决定创建一个库来帮我们解决这个问题。ViewModel 类是其中一个。我们所有的 ViewModel 类都应该继承自它。

让我们的 MainViewModel 继承来自于生命周期感知组件的 ViewModel。首先,我们需要在 build.gradle 文件中添加依赖:

dependencies {
    ... 

    implementation "android.arch.lifecycle:runtime:1.0.0-alpha9"
    implementation "android.arch.lifecycle:extensions:1.0.0-alpha9"
    kapt "android.arch.lifecycle:compiler:1.0.0-alpha9"
}

然后继承 ViewModel:

package me.fleka.modernandroidapp

import android.arch.lifecycle.ViewModel

class MainViewModel : ViewModel() {
    ...
}

在 MainActivity 的 onCreate() 方法中,你应该这样:

class MainActivity : AppCompatActivity() {

    lateinit var binding: ActivityMainBinding

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

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
        binding.executePendingBindings()

    }
}

注意到我们已经没有创建 MainViewModel 的实例了。现在,我们从 ViewModelProviders 中获取它。ViewModelProviders 是一个功能类,有一个获取 ViewModelProvider 的方法。和作用范围相关,所以,如果你在 Activity 调用 ViewModelProviders.of(this) ,那么你的
ViewModel 会存活直到 Activity 被销毁(被销毁而且没有被重新创建)。类似地,如果你在 Fragment 中调用,你的 ViewModel 也会存活直到 Fragment 被销毁。看下下面的图解:

ViewModelProvider 的职责是在第一次调用的时候创建实例,并在 Activity/Fragment 重新创建时返回旧的实例。

不要混淆了:

MainViewModel::class.java

在 Kotlin 中,如果你仅仅写成:

MainViewModel::class

它会返回一个KClass,和 Java 中的 Class 不一样。因此,如果我们加上.java,它表示:

返回一个和给定的 KClass 实例关联的Java 类实例。

现在让我们来旋转一下屏幕看看会发生什么:


我们的数据和旋转之前一样。

上一篇文章中,我说过我们的 app 将会获取 GitHub 仓库列表并展示。要想完成它,我们需要添加
getRepositories 函数,它会返回一个伪造的仓库列表:

class RepoModel {

    fun refreshData(onDataReadyCallback: OnDataReadyCallback) {
        Handler().postDelayed({ onDataReadyCallback.onDataReady("new data") },2000)
    }

    fun getRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {
        var arrayList = ArrayList<Repository>()
        arrayList.add(Repository("First", "Owner 1", 100 , false))
        arrayList.add(Repository("Second", "Owner 2", 30 , true))
        arrayList.add(Repository("Third", "Owner 3", 430 , false))

        Handler().postDelayed({ onRepositoryReadyCallback.onDataReady(arrayList) },2000)
    }
}

interface OnDataReadyCallback {
    fun onDataReady(data : String)
}

interface OnRepositoryReadyCallback {
    fun onDataReady(data : ArrayList<Repository>)
}

同时,我们的 MainViewModel 会有一个调用 getRepositories 的函数:

class MainViewModel : ViewModel() {
    ...
    var repositories = ArrayList<Repository>()

    fun refresh(){
        ...
    }

    fun loadRepositories(){
        isLoading.set(true)
        repoModel.getRepositories(object : OnRepositoryReadyCallback{
            override fun onDataReady(data: ArrayList<Repository>) {
                isLoading.set(false)
                repositories = data
            }
        })
    }
}

最后,我们需要在 RecyclerView 中展示这些仓库。要这么做,我们必须:

  • 创建 rv_item_repository.xml 布局

  • activity_main.xml 布局中添加 RecyclerView

  • 创建 RepositoryRecyclerViewAdapter

  • set adapter

创建 rv_item_repository.xml 我将使用 CardView 库,所以我们要在 build.gradle 中添加依赖:

implementation 'com.android.support:cardview-v7:26.0.1'

布局看起来是这样的:

<?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>

        <import type="android.view.View" />

        <variable
            name="repository"
            type="me.fleka.modernandroidapp.uimodels.Repository" />
    </data>

    <android.support.v7.widget.CardView
        android:layout_width="match_parent"
        android:layout_height="96dp"
        android:layout_margin="8dp">

        <android.support.constraint.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <TextView
                android:id="@+id/repository_name"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginEnd="16dp"
                android:layout_marginStart="16dp"
                android:text="@{repository.repositoryName}"
                android:textSize="20sp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintHorizontal_bias="0.0"
                app:layout_constraintLeft_toLeftOf="parent"
                app:layout_constraintRight_toRightOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintVertical_bias="0.083"
                tools:text="Modern Android App" />

            <TextView
                android:id="@+id/repository_has_issues"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginEnd="16dp"
                android:layout_marginStart="16dp"
                android:layout_marginTop="8dp"
                android:text="@string/has_issues"
                android:textStyle="bold"
                android:visibility="@{repository.hasIssues ? View.VISIBLE : View.GONE}"
                app:layout_constraintBottom_toBottomOf="@+id/repository_name"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintHorizontal_bias="1.0"
                app:layout_constraintStart_toEndOf="@+id/repository_name"
                app:layout_constraintTop_toTopOf="@+id/repository_name"
                app:layout_constraintVertical_bias="1.0" />

            <TextView
                android:id="@+id/repository_owner"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:layout_marginEnd="16dp"
                android:layout_marginStart="16dp"
                android:text="@{repository.repositoryOwner}"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@+id/repository_name"
                app:layout_constraintVertical_bias="0.0"
                tools:text="Mladen Rakonjac" />

            <TextView
                android:id="@+id/number_of_starts"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:layout_marginEnd="16dp"
                android:layout_marginStart="16dp"
                android:layout_marginTop="8dp"
                android:text="@{String.valueOf(repository.numberOfStars)}"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintHorizontal_bias="1"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@+id/repository_owner"
                app:layout_constraintVertical_bias="0.0"
                tools:text="0 stars" />

        </android.support.constraint.ConstraintLayout>

    </android.support.v7.widget.CardView>

</layout>

下一步,在 activity_main.xml 中添加 RecyclerView。别忘了添加依赖:

implementation 'com.android.support:recyclerview-v7:26.0.1'

接下来是布局:

<?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>

        <import type="android.view.View"/>

        <variable
            name="viewModel"
            type="me.fleka.modernandroidapp.MainViewModel" />
    </data>

    <android.support.constraint.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context="me.fleka.modernandroidapp.MainActivity">

        <ProgressBar
            android:id="@+id/loading"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:indeterminate="true"
            android:visibility="@{viewModel.isLoading ? View.VISIBLE : View.GONE}"
            app:layout_constraintBottom_toTopOf="@+id/refresh_button"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <android.support.v7.widget.RecyclerView
            android:id="@+id/repository_rv"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:indeterminate="true"
            android:visibility="@{viewModel.isLoading ? View.GONE : View.VISIBLE}"
            app:layout_constraintBottom_toTopOf="@+id/refresh_button"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:listitem="@layout/rv_item_repository" />

        <Button
            android:id="@+id/refresh_button"
            android:layout_width="160dp"
            android:layout_height="40dp"
            android:layout_marginBottom="8dp"
            android:layout_marginEnd="8dp"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:onClick="@{() -> viewModel.loadRepositories()}"
            android:clickable="@{viewModel.isLoading ? false : true}"
            android:text="Refresh"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="1.0" />

    </android.support.constraint.ConstraintLayout>

</layout>

我们删除了一些之前创建的 TextView 元素,并且按钮现在触发的是 loadRepositories 而不是 refresh:

<Button
    android:id="@+id/refresh_button"
    android:onClick="@{() -> viewModel.loadRepositories()}" 
    ...
    />

删掉 MainViewModel 中的 refresh 和 RepoModel 中的 refreshData 函数。

现在,为 RecyclerView 添加一个适配器:

class RepositoryRecyclerViewAdapter(private var items: ArrayList<Repository>,
                                    private var listener: OnItemClickListener)
    : RecyclerView.Adapter<RepositoryRecyclerViewAdapter.ViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
        val layoutInflater = LayoutInflater.from(parent?.context)
        val binding = RvItemRepositoryBinding.inflate(layoutInflater, parent, false)
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int)
            = holder.bind(items[position], listener)

    override fun getItemCount(): Int = items.size

    interface OnItemClickListener {
        fun onItemClick(position: Int)
    }

    class ViewHolder(private var binding: RvItemRepositoryBinding) :
            RecyclerView.ViewHolder(binding.root) {

        fun bind(repo: Repository, listener: OnItemClickListener?) {
            binding.repository = repo
            if (listener != null) {
                binding.root.setOnClickListener({ _ -> listener.onItemClick(layoutPosition) })
            }

            binding.executePendingBindings()
        }
    }

}

ViewHolder 接受 RvItemRepositoryBinding 类型的实例,而不是 View 类型,这样我们就能在 ViewHolder 中为每一项实现数据绑定。同时,别被下面一行函数给弄迷糊了:

override fun onBindViewHolder(holder: ViewHolder, position: Int)            = holder.bind(items[position], listener)

它只是这种形式的缩写:

override fun onBindViewHolder(holder: ViewHolder, position: Int){
    return holder.bind(items[position], listener)
}

并且 items[position] 实现了索引操作,和 items.get(position) 是一样的。

还有一行可能会迷惑的代码:

binding.root.setOnClickListener({ _ -> listener.onItemClick(layoutPosition) })

你可以用_来代替参数,如果你不需要用它的话。

我们添加了适配器,但在 MainActivity 中还没有把它设置到 RecyclerView 中:

class MainActivity : AppCompatActivity(), RepositoryRecyclerViewAdapter.OnItemClickListener {

    lateinit var binding: ActivityMainBinding

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

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        val viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
        binding.viewModel = viewModel
        binding.executePendingBindings()

        binding.repositoryRv.layoutManager = LinearLayoutManager(this)
        binding.repositoryRv.adapter = RepositoryRecyclerViewAdapter(viewModel.repositories, this)

    }

    override fun onItemClick(position: Int) {
        TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
    }
}

让我们来运行试试:


很奇怪。发生了啥?

  • Activity 被创建了,所以新的适配器也创建了,但里面的 repositories 实际上是空的

  • 我们点击了按钮

  • 调用了 loadRepositories 函数,显示了进度条

  • 2 秒后,我们拿到了仓库列表,隐藏了进度条,但仓库列表没显示。因为没有调用 notifyDataSetChanged

  • 一旦我们旋转屏幕,新的 Activity 被创建,带有仓库参数的新的适配器也被创建了,所以实际上 viewModel 是有数据的。

那么,MainViewModel 该怎样才能通知 MainActivity 更新了项目,好让我们可以调用 notifyDataSetChanged 呢?

不应该这样做。

这点非常重要:MainViewModel 不应该知道任何关于MainActivity的东西。

MainActivity 才拥有 MainViewModel实例,所以应该让它来监听数据变化并通知Adapter。那怎么做?

我们可以观察repositories,这样一旦数据改变了,我们就能改变我们的 adapter。

这个方案中可能出错的地方?

我们先来看看下面的场景:

  • 在 MainActivity 中,我们观察了 repositories:一旦改变,我们调用 notifyDataSetChanged

  • 我们点击了按钮

  • 当我们等待数据改变时,MainActivity 可能会因为配置改变而被重新创建

  • 我们的 MainViewModel 依然存在

  • 2 秒后,我们的 repositories 获得新的数据,然后通知观察者数据已经改变

  • 观察者尝试调用不再存在的 adapternotifyDataSetChanged,因为 MainActivity 已经重新创建了

所以,我们的方案还不够好。

介绍 LiveData

LiveData 是另一个生命周期感知的组件。它能观察 View 的生命周期。这样一来,一旦 Activity 因为配置改变而被销毁,LiveData 就能够知道,它也就能够从被销毁的 Activity 中回收观察者。

让我们在 MainViewModel 中实现它:

class MainViewModel : ViewModel() {
    var repoModel: RepoModel = RepoModel()

    val text = ObservableField("old data")

    val isLoading = ObservableField(false)

    var repositories = MutableLiveData<ArrayList<Repository>>()

    fun loadRepositories() {
        isLoading.set(true)
        repoModel.getRepositories(object : OnRepositoryReadyCallback {
            override fun onDataReady(data: ArrayList<Repository>) {
                isLoading.set(false)
                repositories.value = data
            }
        })
    }
}

然后在 MainActivity 中观察改动:

class MainActivity : LifecycleActivity(), RepositoryRecyclerViewAdapter.OnItemClickListener {

    private lateinit var binding: ActivityMainBinding
    private val repositoryRecyclerViewAdapter = RepositoryRecyclerViewAdapter(arrayListOf(), this)

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

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        val viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
        binding.viewModel = viewModel
        binding.executePendingBindings()

        binding.repositoryRv.layoutManager = LinearLayoutManager(this)
        binding.repositoryRv.adapter = repositoryRecyclerViewAdapter
        viewModel.repositories.observe(this,
                Observer<ArrayList<Repository>> { it?.let{ repositoryRecyclerViewAdapter.replaceData(it)} })

    }

    override fun onItemClick(position: Int) {
        TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
    }
}

it关键字是什么意思呢?如果某个函数只有一个参数,那么那个参数就可以用it来代替。假设我们有个乘以 2 的 lambda 表达式:

((a) -> 2 * a) 

我们可以替换成这样:

(it * 2)

如果你现在运行,你会看到一切都正常工作了:


为什么相比 MVP 我更倾向于 MVVM?

  • 没有 View 的无聊的接口,因为 ViewModel 没有 View 的引用。

  • 没有 Presenter 的无聊的接口,因为根本不需要。

  • 更容易处理配置改动。

  • 使用 MVVM,Activity,Fragment 里的代码更少。

存储库模式

image

我之前说过,Model 是准备数据的抽象层。通常,它包括存储和数据类。每个实体(数据)类都应该对应存储类。例如,如果我们有个 UserPost 数据类,我们应该也有 UserRepositoryPostRepository 类。所有的数据都应该直接从它们中获取。我们永远不应该在 View 或者 ViewModel 中调用 Shared Preferences 或者 DB 实例。

所以,我们可以重命名我们的 RepoModel 为 GitRepoRepositoryGitRepo 从 GitHub 仓库中获取,Repository 从存储库模式中获取。

class GitRepoRepository {

    fun getGitRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {
        var arrayList = ArrayList<Repository>()
        arrayList.add(Repository("First", "Owner 1", 100, false))
        arrayList.add(Repository("Second", "Owner 2", 30, true))
        arrayList.add(Repository("Third", "Owner 3", 430, false))

        Handler().postDelayed({ onRepositoryReadyCallback.onDataReady(arrayList) }, 2000)
    }
}

interface OnRepositoryReadyCallback {
    fun onDataReady(data: ArrayList<Repository>)
}

MainViewModelGitRepoRepsitories 获取 GitHub 仓库列表,但 GitRepoRepositories 又是从哪来的呢?你可以在 repository 中调用
client 或者 DB 实例直接去拿,但这仍然不是最佳实践。你必须尽可能地模块化你的 app。如果你用不同的客户端,用 Retrofit 替代 Volley 呢?如果你在里面写了一点逻辑,你很难去重构它。你的 repository 不需要知道你正在使用哪一个客户端来获取远程数据。

  • repository 需要知道的唯一一件事是数据从远程还是本地获取的。不需要知道我们是如何从远程或者本地获取。

  • ViewModel 需要的唯一一件事是数据

  • View 需要做的唯一一件事就是展示数据

我刚开始开发 Android 时,我曾经想知道应用时如何离线工作的,如何同步数据。好的应用架构允许我们让这些变得简单。例如,当 ViewModel 中的 loadRepositories 被调用时,如果有连接网络,GitRepoRepositories 就能从远程数据源中获取数据,然后保存到本地。一旦手机处于离线模式,GitRepoRepository 就能从本地数据源获取数据。这样一来,Repositories 就应该有 RemoteDataSourceLocalDataSource 的实例,以及处理数据从哪里来的逻辑。

让我们先来添加本地数据源:

class GitRepoLocalDataSource {

    fun getRepositories(onRepositoryReadyCallback: OnRepoLocalReadyCallback) {
        var arrayList = ArrayList<Repository>()
        arrayList.add(Repository("First From Local", "Owner 1", 100, false))
        arrayList.add(Repository("Second From Local", "Owner 2", 30, true))
        arrayList.add(Repository("Third From Local", "Owner 3", 430, false))

        Handler().postDelayed({ onRepositoryReadyCallback.onLocalDataReady(arrayList) }, 2000)
    }

    fun saveRepositories(arrayList: ArrayList<Repository>){
        //todo save repositories in DB
    }
}

interface OnRepoLocalReadyCallback {
    fun onLocalDataReady(data: ArrayList<Repository>)
}

我们有两个方法:首先返回伪造的本地数据,其次就是保存数据。

现在来添加远程数据源:

class GitRepoRemoteDataSource {

    fun getRepositories(onRepositoryReadyCallback: OnRepoRemoteReadyCallback) {
        var arrayList = ArrayList<Repository>()
        arrayList.add(Repository("First from remote", "Owner 1", 100, false))
        arrayList.add(Repository("Second from remote", "Owner 2", 30, true))
        arrayList.add(Repository("Third from remote", "Owner 3", 430, false))

        Handler().postDelayed({ onRepositoryReadyCallback.onRemoteDataReady(arrayList) }, 2000)
    }
}

interface OnRepoRemoteReadyCallback {
    fun onRemoteDataReady(data: ArrayList<Repository>)
}

这个只有一个方法返回伪造的远程数据。

现在可以在我们的 repository 中添加一些逻辑了:

class GitRepoRepository {

    val localDataSource = GitRepoLocalDataSource()
    val remoteDataSource = GitRepoRemoteDataSource()

    fun getRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {
       remoteDataSource.getRepositories( object : OnRepoRemoteReadyCallback {
           override fun onDataReady(data: ArrayList<Repository>) {
               localDataSource.saveRepositories(data)
               onRepositoryReadyCallback.onDataReady(data)
           }

       })
    }
}

interface OnRepositoryReadyCallback {
    fun onDataReady(data: ArrayList<Repository>)
}

所以,分离数据源可以让我们更容易把数据保存到本地。

如果你只需要从网络获取数据,你仍需要存储库模式吗?是的。这会让你的代码更容易测试,其他开发者也能更好地理解你的代码,你也可以更快地维护。:)

Android 管理封装器

如果你想要在 GitRepoRepository 中检查网络连接,这样你就可以知道用哪个数据源获取数据呢?我们已经说过我们不应该在 ViewModelsModels里放任何 Android 相关的代码,那么怎么处理这个问题呢?

让我们来创造一个网络连接的封装器:

class NetManager(private var applicationContext: Context) {
    private var status: Boolean? = false

    val isConnectedToInternet: Boolean?
        get() {
            val conManager = applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
            val ni = conManager.activeNetworkInfo
            return ni != null && ni.isConnected
        }
}

如果我们在 manifest 中添加权限的话上面的代码就可以起作用了:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />

但是因为我们没有 context,如何在 Repository 中创建实例呢?我们可以在构造器中得到:

class GitRepoRepository (context: Context){

    val localDataSource = GitRepoLocalDataSource()
    val remoteDataSource = GitRepoRemoteDataSource()
    val netManager = NetManager(context)

    fun getRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {
        remoteDataSource.getRepositories(object : OnRepoRemoteReadyCallback {
            override fun onDataReady(data: ArrayList<Repository>) {
                localDataSource.saveRepositories(data)
                onRepositoryReadyCallback.onDataReady(data)
            }

        })
    }
}

interface OnRepositoryReadyCallback {
    fun onDataReady(data: ArrayList<Repository>)
}

我们之前在 ViewModel 中创建了 GitRepoRepository 的实例,因为我们的 NetManager 需要一个 Context,我们怎样在 ViewModel 中拿到?你可以从生命周期感知的组件库中拿到 AndroidViewModel,它有一个 context。这个 context 是应用的上下文,而不是 Activity 的:

class MainViewModel : AndroidViewModel  {

    constructor(application: Application) : super(application)

    var gitRepoRepository: GitRepoRepository = GitRepoRepository(NetManager(getApplication()))

    val text = ObservableField("old data")

    val isLoading = ObservableField(false)

    var repositories = MutableLiveData<ArrayList<Repository>>()

    fun loadRepositories() {
        isLoading.set(true)
        gitRepoRepository.getRepositories(object : OnRepositoryReadyCallback {
            override fun onDataReady(data: ArrayList<Repository>) {
                isLoading.set(false)
                repositories.value = data
            }
        })
    }
}

这一行:

constructor(application: Application) : super(application)

我们为 MainViewModel 定义了一个构造器。这是必要的,因为 AndroidViewModel 在它的构造器中请求了 Application 实例。所以在我们的构造器中可以调用 super 方法,这样被我们继承的 AndroidViewModel 的构造器就会被调用。

注意:我们可以用一行代码来表示:

class MainViewModel(application: Application) : AndroidViewModel(application) {
... 
}

现在,我们在 GitRepoRepository 中有了 NetManager 实例,我们就可以检查网络连接了:

class GitRepoRepository(val netManager: NetManager) {

    val localDataSource = GitRepoLocalDataSource()
    val remoteDataSource = GitRepoRemoteDataSource()

    fun getRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {

        netManager.isConnectedToInternet?.let {
            if (it) {
                remoteDataSource.getRepositories(object : OnRepoRemoteReadyCallback {
                    override fun onRemoteDataReady(data: ArrayList<Repository>) {
                        localDataSource.saveRepositories(data)
                        onRepositoryReadyCallback.onDataReady(data)
                    }
                })
            } else {
                localDataSource.getRepositories(object : OnRepoLocalReadyCallback {
                    override fun onLocalDataReady(data: ArrayList<Repository>) {
                        onRepositoryReadyCallback.onDataReady(data)
                    }
                })
            }
        }

    }
}

interface OnRepositoryReadyCallback {
    fun onDataReady(data: ArrayList<Repository>)
}

如果我们连接了网络,我们就获取远程数据然后保存到本地。否则,我们就从本地拿数据。

Kotlin 笔记let 操作符会检查是否为空并返回一个 it 值。

接下来的文章中,我会介绍依赖注入,为什么在 ViewModel 中创建 repository 实例是不好的,以及如何避免使用 AndroidViewModel。

英文原文:Modern Android development with Kotlin (Part 2)
旧文推荐:
用 Kotlin 开发现代 Android 项目 Part 1
Kotlin 让使用 Android API 变得轻松
“Effective Java” 可能对 Kotlin 的设计造成了怎样的影响——第一部分

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,743评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,296评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,285评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,485评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,581评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,821评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,960评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,719评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,186评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,516评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,650评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,329评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,936评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,757评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,991评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,370评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,527评论 2 349

推荐阅读更多精彩内容