使用Koin和Kotlin搭建简单的MVVM框架(上)

介绍

这篇文章,我们将了解如何使用Koin库来搭建易于扩展/编辑的Android App

基础知识

Coroutines(协程)
Kotlin
选择Kotlin的主要原因是因为Kotlin使Android开发更快、更好、更简洁。
Koin:轻量级依赖注入框架。

至于设计模式,Android开发目前基本上有两种主要设计模式:MVPMVVM。我们将使用MVVM因为谷歌推荐用新LiveDataViewModel库(Android架构组件)来搭建APP框架。

本APP将使用theCatApi。它将包含一个简单的MainActivity,该活动将显示猫图像列表😺 。为了展示如何扩展App,如果需要,我可能会在下一个篇上添加一些额外的功能。

咱们开始吧

创建一个带有空活动的新项目。将应用命名为“DemoMeow”,并选择Kotlin作为语言。设置最小API 为21并单击finish完成。

我们将在Kotlin中开发,所以不要忘记检查Kotlin的插件,并在Gradle文件中实现其依赖项(如果需要):

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
    ///...
dependencies { 
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.20'
    ///...
}

对于这个项目,我们将在Gradle dependencies中添加以下库:

dependencies { 
    ///...
// Glide for loading and caching cat images
implementation 'com.github.bumptech.glide:glide:4.9.0'
kapt 'com.github.bumptech.glide:compiler:4.9.0'
// Retrofit as our REST service
implementation 'com.squareup.retrofit2:retrofit:2.5.0'
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.5.0'
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
// Koin for the dependencies injections
implementation 'org.koin:koin-android-viewmodel:2.0.0-rc-2'
// Coroutines for asynchronous calls (and Deferred’s adapter)
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.0'
// Coroutines - Deferred adapter
implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2'
}

Model 层

package com.gunaya.demo.demomeow.data.entities

import com.google.gson.annotations.SerializedName

data class Cat(
    val id: String,
    @SerializedName("url")
    val imageUrl: String
)
/* Cat response example
  {
    "id": "89f",
    "url": "https://25.media.tumblr.com/tumblr_lznbbvPuZy1r63pb5o1_250.gif",
    "breeds": [],
    "categories": []
  }
 */

请注意@SerializedName注解,它将告诉RetrofitJSON响应的url字段必须与我们模型的imageUrl字段相关联。
我们现在需要一个类,它允许我们使用这个模型进行操作。

为了良好实践(以及更简单的测试)本App从Interface访问存储库。

为了拥有一个干净的代码,我们将创建一个名为CatRepository的接口,并在CatRepositoryImpl类中实现它。该接口将提供与cat相关的所有操作函数(目前只有一个方法——getCatList(),但是,例如,如果以后我们想要检索一个cat,我们只需要在接口中添加getCat(s:Name)方法并在实现类中添加其逻辑,就可以更新该存储库)。

CatRepository及其实现将编写如下:

package com.gunaya.demo.demomeow.data.repositories

import com.gunaya.demo.demomeow.UseCaseResult
import com.gunaya.demo.demomeow.data.entities.Cat
import com.gunaya.demo.demomeow.data.remote.CatApi
import java.lang.Exception

// 咱们需要的数量
const val NUMBER_OF_CATS = 30

interface CatRepository {
    // Suspend挂起函数
    suspend fun getCatList(): UseCaseResult<List<Cat>>
}

class CatRepositoryImpl(private val catApi: CatApi) : CatRepository {
    override suspend fun getCatList(): UseCaseResult<List<Cat>> {
      /*
    我们试图从API中返回猫的列表
    等待web服务的结果,然后返回它,捕获API中的任何错误
*/
        return try {
            val result = catApi.getCats(limit = NUMBER_OF_CATS).await()
            UseCaseResult.Success(result)
        } catch (ex: Exception) {
            UseCaseResult.Error(ex)
        }
    }
}

如果请求成功,UseCaseResult类将有助于获取数据,如果请求失败,将有助于获取异常。


sealed class UseCaseResult<out T : Any> {
    class Success<out T : Any>(val data: T) : UseCaseResult<T>()
    class Error(val exception: Throwable) : UseCaseResult<Nothing>()
}

另外,请注意CatRepositoryImpl构造函数中的CatApi参数,它是我们的REST接口。按如下方式创建:

import com.gunaya.demo.demomeow.data.entities.Cat
import kotlinx.coroutines.Deferred
import retrofit2.http.GET
import retrofit2.http.Query

interface CatApi {
    /* 获取用于检索cat图像的路径,limit是获取cat的数量*/
    @GET("images/search")
    fun getCats(@Query("limit") limit: Int)
            : Deferred<List<Cat>>
}

ViewModel 层


ViewModel类旨在以生命周期意识的方式存储和管理UI相关的数据

让我们创建一个Kotlin类,将其命名为MainViewModel并从Google中实现ViewModel。它将是与我们的MainActivity相关联的viewModel。

class MainViewModel(private val catRepository: CatRepository) : ViewModel(), CoroutineScope {
    // Coroutine's background job
    private val job = Job()
    // Define default thread for Coroutine as Main and add job
    override val coroutineContext: CoroutineContext = Dispatchers.Main + job

    val showLoading = MutableLiveData<Boolean>()
    val catsList = MutableLiveData<List<Cat>>()
    val showError = SingleLiveEvent<String>()

    fun loadCats() {
        // Show progressBar during the operation on the MAIN (default) thread
        showLoading.value = true
        // launch the Coroutine
        launch {
            // Switching from MAIN to IO thread for API operation
            // Update our data list with the new one from API
            val result = withContext(Dispatchers.IO) { catRepository.getCatList() }
            // Hide progressBar once the operation is done on the MAIN (default) thread
            showLoading.value = false
            when (result) {
                is UseCaseResult.Success -> catsList.value = result.data
                is UseCaseResult.Error -> showError.value = result.exception.message
            }
        }
    }

    override fun onCleared() {
        super.onCleared()
        // Clear our job when the linked activity is destroyed to avoid memory leaks
        job.cancel()
    }
}

-CoroutineScope必须在将要切换线程的类中实现。随之而来的是我们要重写CoroutineContext。
-正如名字所说,MutableLiveData类型是一个可变的LiveData(我们可以设置值)。
-简而言之,SingleLiveEvent类型是只触发一个事件的LiveData。更多信息此处(将这个类从Google添加到项目中。)→ SingleLiveEvent.kt)
-showLoading是一个布尔值,我们的视图将根据其值观察并更新progressBar。
-catsList是我们的视图将观察和显示的数据列表。
下一步是填充我们的猫对象。为了实现它,我们必须用Koin设置我们新创建的API接口、CatRepositoryviewModel

Koin 模型的实现

现在,让我们创建模块文件,在其中设置Koin的模块。我们必须在这个文件中编写如何构建类的逻辑,所以当我们需要其中一个类时,我们只需告诉Koin需要哪一个,他就会依赖注入给我们。

const val CAT_API_BASE_URL = "https://api.thecatapi.com/v1/"

val appModules = module {
    // The Retrofit service using our custom HTTP client instance as a singleton
    single {
        createWebService<CatApi>(
            okHttpClient = createHttpClient(),
            factory = RxJava2CallAdapterFactory.create(),
            baseUrl = CAT_API_BASE_URL
        )
    }
    // Tells Koin how to create an instance of CatRepository
    factory<CatRepository> { CatRepositoryImpl(catApi = get()) }
    // Specific viewModel pattern to tell Koin how to build MainViewModel
    viewModel { MainViewModel(catRepository = get()) }
}

/* Returns a custom OkHttpClient instance with interceptor. Used for building Retrofit service */
fun createHttpClient(): OkHttpClient {
    val client = OkHttpClient.Builder()
    client.readTimeout(5 * 60, TimeUnit.SECONDS)
    return client.addInterceptor {
        val original = it.request()
        val requestBuilder = original.newBuilder()
        requestBuilder.header("Content-Type", "application/json")
        val request = requestBuilder.method(original.method(), original.body()).build()
        return@addInterceptor it.proceed(request)
    }.build()
}
/* function to build our Retrofit service */
inline fun <reified T> createWebService(
    okHttpClient: OkHttpClient,
    factory: CallAdapter.Factory, baseUrl: String
): T {
    val retrofit = Retrofit.Builder()
        .baseUrl(baseUrl)
        .addConverterFactory(GsonConverterFactory.create(GsonBuilder().setLenient().create()))
        .addCallAdapterFactory(CoroutineCallAdapterFactory())
        .addCallAdapterFactory(factory)
        .client(okHttpClient)
        .build()
    return retrofit.create(T::class.java)
}

这里需要注意三点:
-模块包含将在·appModule·范围内组装的类(在本例中是我们的改装服务和CatRepository)。
-为了构建改造服务,我们将使用createWebService()作为单例,并使用我们的自定义OkHttpClient作为参数,使用函数createWebService()
-我们的CatRepository在这里使用Koinget()来满足构造函数的参数。无论何时我们需要它,Koin都会在我们使用的时候注入给我们提供使用。
我们现在需要在项目中添加这个appModules,以便在应用程序中访问它。让我们创建DemoMeowApplication类,它将继承Application,并使用我们的AppModule启动Koin

class DemoMeowApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        // Adding Koin modules to our application
        startKoin {
            androidContext(this@DemoMeowApplication)
            modules(appModules)
        }
    }
}

我们需要更新AndroidManifest以覆盖默认的应用程序类。另外,别忘了添加互联网权限。
使用以下行更新清单:

   ///...
<uses-permission android:name="android.permission.INTERNET" />
<application>
    android:name=".application.DemoMeowApplication"
    ///...
</application>

View层

现在我们已经准备好了所有的逻辑,我们需要向用户展示它们。
这就是观点的来源。它的简单目的是显示数据。
我们首先准备适配器,它将处理要显示的列表:

class CatAdapter : RecyclerView.Adapter<CatAdapter.CatViewHolder>() {

    // Our data list is going to be notified when we assign a new list of data to it
    private var catsList: List<Cat> by Delegates.observable(emptyList()) { _, _, _ ->
        notifyDataSetChanged()
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CatViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val view = inflater.inflate(R.layout.item_cat, parent, false)
        return CatViewHolder(view)
    }

    override fun getItemCount(): Int = catsList.size

    override fun onBindViewHolder(holder: CatViewHolder, position: Int) {
        // Verify if position exists in list
        if (position != RecyclerView.NO_POSITION) {
            val cat: Cat = catsList[position]
            holder.bind(cat)
        }
    }

    // Update recyclerView's data
    fun updateData(newCatsList: List<Cat>) {
        catsList = newCatsList
    }

    class CatViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        fun bind(cat: Cat) {
            // Load images using Glide library
            Glide.with(itemView.context)
                .load(cat.imageUrl)
                .centerCrop()
                .thumbnail()
                .into(itemView.itemCatImageView)
        }
    }
}

(item_cat.xml)文件 :

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="120dp"
    android:padding="1dp">

    <ImageView
        android:id="@+id/itemCatImageView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:adjustViewBounds="true" />

</android.support.constraint.ConstraintLayout>

最后,让我们实现MainActivityView层)逻辑:

const val NUMBER_OF_COLUMN = 3

class MainActivity : AppCompatActivity() {

    // Instantiate viewModel with Koin
    private val viewModel: MainViewModel by viewModel()
    private lateinit var catAdapter: CatAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        // Instantiate our custom Adapter
        catAdapter = CatAdapter()
        catsRecyclerView.apply {
            // Displaying data in a Grid design
            layoutManager = GridLayoutManager(this@MainActivity, NUMBER_OF_COLUMN)
            adapter = catAdapter
        }
        // Initiate the observers on viewModel fields and then starts the API request
        initViewModel()
    }
    
    private fun initViewModel() {
        // Observe catsList and update our adapter if we get new one from API
        viewModel.catsList.observe(this, Observer { newCatsList ->
            catAdapter.updateData(newCatsList!!)
        })
        // Observe showLoading value and display or hide our activity's progressBar
        viewModel.showLoading.observe(this, Observer { showLoading ->
            mainProgressBar.visibility = if (showLoading!!) View.VISIBLE else View.GONE
        })
        // Observe showError value and display the error message as a Toast
        viewModel.showError.observe(this, Observer { showError ->
            Toast.makeText(this, showError, Toast.LENGTH_SHORT).show()
        })
        // The observers are set, we can now ask API to load a data list
        viewModel.loadCats()
    }
}

简单说明:首先,我们使用Koin实例化viewModel,然后,我们用观察者模式去观察viewModel的数据。最后,我们用viewModel启动API请求loadCats(),然后在viewModel中加载数据,这会在第39行触发观察者,并通过调用updateData()更新视图的适配器。

MainActivity的布局文件 (activity_main.xml):

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".presentation.MainActivity">

    <ProgressBar
        android:id="@+id/mainProgressBar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:visibility="visible" />

    <android.support.v7.widget.RecyclerView
        android:id="@+id/catsRecyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</android.support.constraint.ConstraintLayout>

现在,你可以运行APP,享受小猫列表了😺
这个实现仍然是基本的,但您已经了解了如何使用Koin实现依赖注入,使用协程路由切换线程(MAIN/IO)来进行API调用,检索数据并使用MVVM模式显示它们。

请随意添加一些实现来扩展此应用程序并加深您的理解。以下是一些想法:一个onImageClick事件,加载更多数据等等。

传送门:Github
请用git checkout 430211e 拉去对应的版本。

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

推荐阅读更多精彩内容