[Android翻译]Kotlin 多平台+Reactive

1,547 阅读18分钟

原文地址:medium.com/@fandygotam…

原文作者:medium.com/@fandygotam…

发布时间:2019年10月13日 - 12分钟阅读

经过长时间的等待和阅读了很多关于多平台项目如何帮助减少应用中相同的业务逻辑的文章后,我决定尝试一下Android和iOS的Kotlin多平台项目。

为什么要等到现在?

有2个原因让我决定等待再开始尝试:

  1. 在Kotlin 1.3.40之前,不支持obj-c/Swift generic,blog.jetbrains.com/kotlin/2019…
  2. 我找到了一个Kotlin多平台的reactive扩展,叫Reaktive github.com/badoo/Reakt…

为什么不使用Coroutines呢,有些人可能会说😁。首先,我们已经有几篇使用Coroutines与Kotlin多平台的教程,其次,目前还没有针对多平台项目的reactive教程(据我所知)。

本教程提供了一个使用Reactive代替Coroutines构建多平台项目的替代方法。本教程大约需要1到2个小时的时间,多准备一些零食和饮料。

不说了,让我们动手吧。

注意事项:

  • 在阅读本教程时,文字之间的粗体字意味着你要仔细观察。
  • 斜体句子是额外的信息,你可以跳过。

目标

我们的目标是通过Kotlin多平台共享相同的业务逻辑,在Android和iOS上做出类似的输出和功能。这将是我们最终的成果。

iOS模拟器

安卓模拟器

注意:你需要macOS兼容平台来运行Xcode。

办法

本教程将使用MVVM与输入输出的方法,灵感来自kickstarters,(github.com/kickstarter…

下面是我们的应用结构设计图。

MVVM图与输入输出方法

在写这篇文章的时候,我使用的是以下库的最新版本:

  • Kotlin, 版本: 1.3.50
  • Reaktive,版本:1.0.0
  • Ktor,版本:1.2.5,类似于安卓的 retrofit / iOS 的 alamofire。
  • Sqldelight, 版本: 1.2.0

集成开发环境(IDE)

  • Android Studio 3.5.1
  • Xcode 11.0

让我们开始吧

项目准备

打开你的Android Studio,然后创建一个android项目,接下来,创建新模块,选择Android库,让我们把它叫做:Core.

创建新模块时选择Android库。

现在进入新创建的项目目录,将app文件夹重命名为android文件夹。关闭你的android studio,然后重新打开,进入settings.gradle,把你的:app改成:android

注:以后如果在项目中加入其他平台,建议与coreandroid目录放在同一级别。

Kotlin多平台中的目录结构示例。

现在让我们从添加依赖关系到多平台项目开始。首先打开项目build.gradle并更新它,将kotlin-serialization classpath和reaktive url添加到仓库中。你的build.gradle应该和这个类似。

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    ext {
        ktor_version = '1.2.5'
        serialization_version = '0.13.0'
        reactive_version = '1.0.0'
        coroutines_version = '1.3.2'
        kotlin_version = '1.3.50'
    }

    repositories {
        google()
        jcenter()

    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.5.1'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        google()
        jcenter()
        maven { url "https://dl.bintray.com/badoo/maven" }
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

项目build.gradle

打开你的核心模块的build.gradle,删除所有的内容,然后用这个替换。

apply plugin: 'kotlin-multiplatform'
apply plugin: 'kotlinx-serialization'

group = 'com.adrena.reaktive.core'
version = 1.0

kotlin {
    targets {
        fromPreset(presets.jvm, 'android')

        final def iOSTarget = System.getenv('SDK_NAME')?.startsWith("iphoneos") ? presets.iosArm64 : presets.iosX64

        fromPreset(iOSTarget, 'iOS') {
            binaries {
                framework('Core') {
                    freeCompilerArgs.add("-Xobjc-generics")
                }
            }
        }
    }

    sourceSets {
        commonMain {
            dependencies {
                implementation "org.jetbrains.kotlin:kotlin-stdlib-common"

                implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime-common:$serialization_version"

                // Coroutines
                implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core-common:$coroutines_version"

                // HTTP
                implementation "io.ktor:ktor-client-core:$ktor_version"
                implementation "io.ktor:ktor-client-json:$ktor_version"
                implementation "io.ktor:ktor-client-logging:$ktor_version"
                implementation "io.ktor:ktor-client-serialization:$ktor_version"

                // Reactive
                implementation "com.badoo.reaktive:reaktive:$reactive_version"
            }
        }

        androidMain {
            dependencies {
                implementation "org.jetbrains.kotlin:kotlin-stdlib"

                implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:$serialization_version"

                // Coroutines
                implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"

                // HTTP
                implementation "io.ktor:ktor-client-android:$ktor_version"
                implementation "io.ktor:ktor-client-core-jvm:$ktor_version"
                implementation "io.ktor:ktor-client-json-jvm:$ktor_version"
                implementation "io.ktor:ktor-client-logging-jvm:$ktor_version"
                implementation "io.ktor:ktor-client-serialization-jvm:$ktor_version"
            }
        }

        iOSMain {
            dependencies {
                implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime-native:$serialization_version"

                // HTTP
                implementation "io.ktor:ktor-client-ios:$ktor_version"
                implementation "io.ktor:ktor-client-core-native:$ktor_version"
                implementation "io.ktor:ktor-client-json-native:$ktor_version"
                implementation "io.ktor:ktor-client-logging-native:$ktor_version"
                implementation "io.ktor:ktor-client-serialization-native:$ktor_version"

            }
        }

        all {
            languageSettings {
                progressiveMode = true
                useExperimentalAnnotation('kotlin.Experimental')
            }
        }
    }
}

// workaround for https://youtrack.jetbrains.com/issue/KT-27170
configurations {
    compileClasspath
}

// Xcode-specific
task packForXCode(type: Sync) {
    final File frameworkDir = new File(buildDir, "xcode-frameworks")
    final String mode = project.findProperty("XCODE_CONFIGURATION")?.toUpperCase() ?: 'DEBUG'
    final def framework = kotlin.targets.iOS.binaries.getFramework("Core", mode)

    inputs.property "mode", mode
    dependsOn framework.linkTask

    from { framework.outputFile.parentFile }
    into frameworkDir

    doLast {
        new File(frameworkDir, 'gradlew').with {
            text = "#!/bin/bash\nexport 'JAVA_HOME=${System.getProperty("java.home")}'\ncd '${rootProject.rootDir}'\n./gradlew \$@\n"
            setExecutable(true)
        }
    }
}
tasks.build.dependsOn packForXCode
dependencies {
}

Core build.gradle

然后打开你的android build.gradle,添加exclude META-INF来删除警告。

packagingOptions {
    exclude 'META-INF/*.kotlin_module'
}

implementation project(‘:core’)添加到你的依赖关系中。你的Android build.gradle应该是这样的。

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'

android {
    compileSdkVersion 29
    buildToolsVersion "29.0.2"
    defaultConfig {
        applicationId "com.adrena.reaktive.tutorial"
        minSdkVersion 17
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    packagingOptions {
        exclude 'META-INF/*.kotlin_module'
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    implementation project(':core')

    implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'androidx.core:core-ktx:1.1.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test.ext:junit:1.1.0'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}

Android build.gradle

你需要注意的事项

  1. Kotlin Native只支持arm32和arm64,目前不支持armv7和armv7s。
  2. 为了使obj-c/swift通用支持,你应该添加freeCompilerArgs.add("-Xobjc-generics")
  3. 我们创建了3个sourceSets,一个是commonMain,用于我们的Kotlin Multiplatform,另外两个是androidMainiOSMain,用于特定平台的代码。
  4. 一些ktor方法被标记为experimental的,添加useExperimentalAnnotation来消除这些警告。
all {
    languageSettings {
        progressiveMode = true
        useExperimentalAnnotation('kotlin.Experimental')
    }
}

允许实验性注释

接下来,把core目录里面的androidTestmaintest目录去掉,创建commonMainandroidMainiOSMain,并在kotlin子文件夹里面创建kotlin作为子文件夹以及包名目录。你的目录结构应该是这样的。

现在我们已经设置好了项目结构和gradle,试着构建项目,让我们继续下一步。

数据层

连接到云服务

我们将连接到OMDB和使用ktor获得电影列表。API是免费的,你可以从这里得到它。

www.omdbapi.com/apikey.aspx

我们将搜索所有包含复仇者关键词的电影与这个网址:www.omdbapi.com/?s=avenger&…

以下是来自API的JSON响应。

 {
  "Search": [
    {
      "Title": "Captain America: The First Avenger",
      "Year": "2011",
      "imdbID": "tt0458339",
      "Type": "movie",
      "Poster": "https://m.media-amazon.com/images/M/MV5BMTYzOTc2NzU3N15BMl5BanBnXkFtZTcwNjY3MDE3NQ@@._V1_SX300.jpg"
    },
    {
      "Title": "The Toxic Avenger",
      "Year": "1984",
      "imdbID": "tt0090190",
      "Type": "movie",
      "Poster": "https://m.media-amazon.com/images/M/MV5BNzViNmQ5MTYtMmI4Yy00N2Y2LTg4NWUtYWU3MThkMTVjNjk3XkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_SX300.jpg"
    }
  ],
  "totalResults": "102",
  "Response": "True"
}

OMDb API响应

现在我们知道了响应,我们将创建DTO(数据传输对象)域模型和一个转换类来将DTO映射到域模型。

@Serializable
data class MoviesResponse(
    @SerialName("Search")
    val items: List<MovieResponse>)

@Serializable
data class MovieResponse(
    @SerialName("Title") val title: String,
    @SerialName("imdbID") val imdbID: String,
    @SerialName("Year") val year: String,
    @SerialName("Type") val type: String,
    @SerialName("Poster") val poster: String
)

DTO,对象模型代表来自数据源的JSON响应。

data class Movie(
    val title: String,
    val imdbID: String,
    val year: String,
    val type: String,
    val poster: String
)

领域模型将在多平台项目中使用,并转移到特定平台上。

interface Mapper<in T, out E> {
    fun transform(response: T): E
}

映射模型的Mapper接口

class MoviesMapper: Mapper<MoviesResponse, List<Movie>> {

    override fun transform(response: MoviesResponse): List<Movie> {
        return response.items.map {
            Movie(
                it.title,
                it.imdbID,
                it.year,
                it.type,
                it.poster
            )
        }
    }
}

从DTO到领域模型的转换过程

它是如何工作的

  1. 我们创建DTO,将来自API的JSON响应序列化到模型中。
  2. 接下来,我们创建域模型,它将在我们的多平台项目中使用。在这个例子中,这似乎是多余的,因为DTO和领域模型都有相同的数据结构,但是在现实生活中,你的领域模型可能有不同的结构或数据类型。
  3. 最后,我们创建transform类来将DTO转化为Domain Model。

现在,当我们准备好数据模型后,我们创建服务接口和它的实现类来连接到API服务器并获得结果。

interface Service<in R, T> {
    suspend fun execute(request: R?): T
}

服务接口

class MoviesCloudService(
    private val key: String,
    private val hostUrl: String,
    private val mapper: Mapper<MoviesResponse, List<Movie>>): Service<String, List<Movie>> {

    @UseExperimental(UnstableDefault::class)
    override suspend fun execute(request: String?): List<Movie> {

        val httpResponse = client.get<HttpResponse> {
            apiUrl()
            parameter("s", request)
        }

        val json = httpResponse.readText()

        val response = Json.nonstrict.parse(MoviesResponse.serializer(), json)

        return mapper.transform(response)
    }

    private fun HttpRequestBuilder.apiUrl(path: String? = null) {
        header(HttpHeaders.CacheControl, "no-cache")
        url {
            takeFrom(hostUrl).parameters.append("apiKey", key)
            path?.let {
                encodedPath = it
            }
        }
    }

    private val client = HttpClient {
        install(JsonFeature) {
            serializer = KotlinxSerializer()
        }
        private val client = HttpClient {
        install(JsonFeature) {
            serializer = KotlinxSerializer()
        }
        install(Logging) {
            logger = Logger.DEFAULT
            level = LogLevel.ALL
        }
    }
    }
}

服务实施

它是如何工作的

  1. 首先我们创建服务接口来提供抽象。
  2. 之后,我们使用ktor安装http客户端,使用内置安装方法安装json功能,并使用默认的kotlin serializer。我们还安装了日志功能来记录API请求和响应。
  3. 接下来,我们在HttpRequestBuilder.apiUrl中通过添加apiKey到host url来准备apiUrl
  4. override suspend fun execute里面,我们通过传递搜索参数来调用OMDB url,并将响应文本序列化到我们的MoviesResponse,然后将其转换为我们的领域模型。

注意:我们使用suspend函数是因为在使用ktor时需要它,因为它的大部分方法都是基于Coroutines的。

存储库

存储库向外界隐藏了数据如何存储和检索的细节。数据存储可以是SQL,云,甚至是文件。与数据层的连接只能通过存储库。

我们来创建一个存储库接口。

interface Repository<in R, T> {
    suspend fun get(request: R?): T
}

存储库接口

class MoviesRepositoryImpl<R>(
    private val service: Service<R, List<Movie>>
): Repository<R, List<Movie>> {

    override suspend fun get(request: R?): List<Movie> {
        return service.execute(request)
    }
}

存储库的实施

现在我们已经完成了数据层的准备,你的目录结构应该类似于这样。

数据层目录结构

领域层

使用案例

用例包含业务逻辑,它们可以包含一个或多个存储库,并提供结果到我们的视图中。

本教程中的用例非常简单,只包含1个存储库来获取我们需要的列表。

interface UseCase<in R, T> {
    suspend fun execute(request: R?): T
}

用例接口

class GetMoviesUseCaseImpl<R>(
    private val repository: Repository<R, List<Movie>>
): UseCase<R, List<Movie>> {

    override suspend fun execute(request: R?): List<Movie> {
        return repository.get(request)
    }
}

用例实施

演示层

Coroutines Interop

ktor大量使用coroutines来做异步任务,我们必须找到一种方法来将suspend fun转化为可观察的流。幸运的是Reaktive为我们提供了一个Coroutines interop来实现这个功能。你可以在你的core的多平台build.gradle里面添加这个实现。

implementation 'com.badoo.reaktive:coroutines-interop:<latest-version>'

在为iOS创建例子的过程中,我发现了一个问题,那就是使用最新的coroutines-interop v1.0.0,ktor不返回任何响应代码,如果你遇到了类似的问题,可以使用下面的代码作为临时修复。

fun Job.asDisposable(): Disposable =
    object : Disposable {
        override val isDisposed: Boolean get() = !isActive

        override fun dispose() {
            cancel()
        }
    }
    
internal inline fun <T> launchCoroutine(
    context: CoroutineContext = Dispatchers.Unconfined,
    crossinline onSuccess: (T) -> Unit,
    crossinline onError: (Throwable) -> Unit,
    crossinline block: suspend () -> T
): Disposable =
    GlobalScope
        .launch(context) {
            try {
                block()
            } catch (e: Throwable) {
                onError(e)
                return@launch
            }
                .also(onSuccess)
        }
        .asDisposable()
        
fun <T> singleFromCoroutine(context: CoroutineContext = Dispatchers.Unconfined, block: suspend () -> T): Single<T> =
    single { emitter ->
        launchCoroutine(
            context = context,
            onSuccess = emitter::onSuccess,
            onError = emitter::onError,
            block = block
        )
            .also(emitter::setDisposable)
    }

singleFromCoroutine临时修复方法

查看模型

ViewModel代表我们要在视图中显示的数据。在这个例子中,我们的视图模型将返回电影列表。

interface ListViewModelInput<in R> {
    fun get(request: R)
    fun loadMore(request: R)
}

interface ListViewModelOutput<R, T> {
    val loading: Observable<Boolean>
    val result: Observable<List<T>>
}

interface ListViewModel<R, T> {
    val inputs: ListViewModelInput<R>
    val outputs: ListViewModelOutput<R, T>
}

视图模型接口

class ListViewModelImpl<R, E>(
    useCase: UseCase<R, List<Movie>>,
    mapper: Mapper<List<Movie>, List<E>>?
): ListViewModel<R, E>, ListViewModelInput<R>, ListViewModelOutput<R, E> {
    override val inputs: ListViewModelInput<R> = this
    override val outputs: ListViewModelOutput<R, E> = this

    override val loading: Observable<Boolean>
    override val result: Observable<List<E>>

    private val mListProperty = publishSubject<R>()
    private val mLoadMoreProperty = publishSubject<R>()

    init {
        val loadingProperty = publishSubject<Boolean>()

        val items = mutableListOf<E>()

        var clearItems = false

        loading = loadingProperty

        val initialRequest = mListProperty
            .doOnBeforeNext { loadingProperty.onNext(true) }
            .flatMapSingle { request ->
                singleFromCoroutine { useCase.execute(request) }
            }
            .doOnBeforeNext {
                loadingProperty.onNext(false)
                clearItems = true
            }

        val nextRequest = mLoadMoreProperty
            .doOnBeforeNext { loadingProperty.onNext(true) }
            .flatMapSingle { request ->
                singleFromCoroutine { useCase.execute(request) }
            }
            .doOnBeforeNext {
                loadingProperty.onNext(false)
                clearItems = false
            }

        result = merge(initialRequest, nextRequest).map {
            if (clearItems) {
                items.clear()
            }

            @Suppress("UNCHECKED_CAST")
            val list = mapper?.transform(it) ?: it as List<E>

            items.addAll(list)

            items
        }

    }

    override fun get(request: R) {
        mListProperty.onNext(request)
    }

    override fun loadMore(request: R) {
        mLoadMoreProperty.onNext(request)
    }

}

查看模型实施

它是如何工作的

  1. 我们创建了视图模型接口,暴露了2个输入:一个是获取电影列表,另一个是加载更多的列表,以及2个输出:一个是显示加载指标,另一个是显示结果。输入代表任何交互或来自视图的输入,而输出代表视图模型的变化,视图必须显示。视图模型之间的通信只能通过这个暴露的输入和输出来实现。
  2. 所有视图模型的过程应该只发生在init构造函数上。我们使用两个发布主题来获取电影列表,另一个主题来加载更多的电影。在flatMapSingle中,我们使用singleFromCoroutine方法将用例的suspend fun转换为Single observable
  3. 接下来我们将两个流合并成一个名为result的observable。有一点需要注意的是,我在视图模型构造函数中加入了mapper,以将域模型映射到展示模型中,例如。Android中的Parcelable模型。

构建封装类

现在我们已经建立了所有的层,在我们转移到Android和iOS之前的最后一部分是创建一个包装类。这个类只是提供了一个访问reaktive subscribe方法的方法,所以它在两个平台上都可以访问。

class ViewModelBinding {
    private val disposables = CompositeDisposable()

    fun <T> subscribe(observable: Observable<T>,
                   isThreadLocal: Boolean = true,
                   onSubscribe: ((Disposable) -> Unit)? = null,
                   onError: ((Throwable) -> Unit)? = null,
                   onComplete: (() -> Unit)? = null,
                   onNext: ((T) -> Unit)? = null) {

        disposables.add(observable.subscribe(isThreadLocal, onSubscribe, onError, onComplete, onNext))
    }

    fun <T> subscribe(observable: Observable<T>, onError: ((Throwable) -> Unit)? = null, onNext: ((T) -> Unit)? = null) {
        disposables.add(observable.subscribe(true, onError = onError, onNext = onNext))
    }

    fun <T> subscribe(observable: Observable<T>, onNext: ((T) -> Unit)? = null) {
        disposables.add(observable.subscribe(true, onNext = onNext))
    }

    fun dispose() {
        disposables.dispose()
    }
}

绑定包装类

界面

现在我们进入UI部分的教程,让我们搭建一个安卓平台,使用回收器视图显示电影列表。

安卓

让我们开始创建Parcelable模型及其映射器。

@Parcelize
data class MovieModel(
    val title: String,
    val imdbID: String,
    val year: String,
    val type: String,
    val poster: String
): Parcelable

Android模型实现Parcelable

class MovieModelsMapper: Mapper<List<Movie>, List<MovieModel>> {

    override fun transform(response: List<Movie>): List<MovieModel> {
        return response.map {
            MovieModel(
                it.title,
                it.imdbID,
                it.year,
                it.type,
                it.poster
            )
        }
    }
}

Android模型映射器

接下来我们将设置android活动。

注意:我不会详细介绍如何设置适配器和视图支架,因为它对android开发者来说是非常直接的过程。

class MainActivity : AppCompatActivity() {
    private lateinit var mMoviesAdapter: MoviesAdapter
    private lateinit var mRecyclerView: RecyclerView
    private lateinit var mRefreshLayout: SwipeRefreshLayout

    private var mIsRefreshing = false
    private val mBinding = ViewModelBinding()
    
    private val mViewModel: ListViewModel<String, MovieModel> by lazy {
        val domainMapper = MoviesMapper()

        // Change xxxxxxx into your OMDb key
        val service = MoviesCloudService("xxxxxxx", "https://www.omdbapi.com/", domainMapper)

        val repository = MoviesRepositoryImpl(service)

        val useCase = UseCaseImpl(repository)

        val modelMapper = MovieModelsMapper()

        ListViewModelImpl(useCase, modelMapper)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        binding()

        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_main)

        mRecyclerView = findViewById(R.id.listing)
        mRefreshLayout = findViewById(R.id.refresh_layout)

        mMoviesAdapter = MoviesAdapter()

        mRecyclerView.layoutManager = GridLayoutManager(this, 2)
        mRecyclerView.adapter = mMoviesAdapter
        mRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)

                val manager = listing.layoutManager as LinearLayoutManager

                val totalItemCount = manager.itemCount
                val lastVisibleItem = manager.findLastVisibleItemPosition()

                if (!mIsRefreshing && totalItemCount <= lastVisibleItem + 2) {
                    loadMore()
                }
            }
        })

        mRefreshLayout.setOnRefreshListener {
            mViewModel.inputs.get("avenger")
        }

        mViewModel.inputs.get("avenger")
    }

    override fun onDestroy() {
        mBinding.dispose()

        super.onDestroy()
    }

    private fun binding() {
        mBinding.subscribe(mViewModel.outputs.loading.observeOn(mainScheduler), onNext = ::loading)
        mBinding.subscribe(mViewModel.outputs.result.observeOn(mainScheduler), onNext = ::result)
    }

    private fun loading(isLoading: Boolean) {
        mIsRefreshing = isLoading
        refresh_layout.isRefreshing = isLoading
    }

    private fun result(movies: List<MovieModel>) {
        mMoviesAdapter.setList(movies)
    }

    private fun loadMore() {
        mViewModel.inputs.loadMore("avenger")
    }
}

显示电影列表的主要活动

它是如何工作的。

  1. 我们先懒惰地准备我们的视图模型;从服务、仓库、用例开始拼接所有必要的部分,并把它们放到ListViewModelImpl中,由于我们要使用可包裹的模型,我们也把MovieModelsMapper传到ViewModel中。另外你也可以使用Dagger 2或Koin通过依赖注入来提供这个视图模型。
  2. onCreate的过程很常见,设置回收器视图,设置适配器和滚动监听器,当用户滚动列表时,如果它们达到特定点,总是加载更多的电影。在onCreate过程中,我们还调用viewmodel.input.get开始从API下载电影列表。
  3. 我们在onDestroy()中处置所有的订阅,以防止内存泄漏。
  4. 在binding()方法里面,我们订阅view模型的输出,我们关心的是:加载结果
  5. 下载完数据后,我们将电影列表设置到适配器中。

iOS

如果你使用的是macOS平台,你可以继续为iOS构建Kotlin多平台,我们先从gradle开始构建iOS框架。在Android Studio终端里面输入这个命令。

./gradlew :core:packForXCode

为iOS创建core.framework

构建成功后,你可以在xcode-frameworks子目录下检查你的框架。

Core.framework for iOS

现在,让我们打开Xcode,点击创建新项目,并将其保存到iOS目录下。

多平台项目中的iOS目录

在导入框架到你的项目中之前,有几个设置。首先点击你的项目,进入Frameworks、Libraries和Embedded Content,将xcode-frameworks目录下的Core.framework拖入其中。

Core.framework在Frameworks, Libraries, and Embedded Content里面。

现在进入Build Settings,搜索Framework Search Paths并输入你的xcode-frameworks路径。如果你从一开始就按照这个例子使用相同的目录结构和名称,你可以输入$(SRCROOT)/.../core/build/xcode-frameworks

将框架路径添加到框架搜索路径中

最后,进入Build Phases,添加New Run Script Phase,把它移到Dependencies部分下面,然后添加这个bash脚本。

cd "$SRCROOT/../core/build/xcode-frameworks"
./gradlew :core:packForXCode -PXCODE_CONFIGURATION=${CONFIGURATION}

Bash脚本

构建阶段的Bash脚本

这个运行脚本将确保我们在构建应用程序时总是能得到最新的框架代码。

构建项目后,你应该可以导入Core

接下来我们将设置ViewController

同样,我也不会详细介绍如何设置UICollectionViewDataSource和UICollectionViewCell。

import UIKit
import Core

class ViewController: UIViewController {
    private var _movies: [Movie]?
    private var _isRefreshing = false
    
    lazy var refreshControl: UIRefreshControl = {
        let v = UIRefreshControl()
        
        v.addTarget(self, action: #selector(refresh), for: .valueChanged)
        
        return v
    }()
    
    lazy var collectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        
        layout.minimumLineSpacing = 20
        layout.scrollDirection = .vertical
        layout.sectionInset = UIEdgeInsets(top: 20, left: 10, bottom: 5, right: 10)
        
        let marginsAndInsets = layout.sectionInset.left + layout.sectionInset.right + layout.minimumInteritemSpacing * CGFloat(2 - 1)
        let itemWidth = ((UIScreen.main.bounds.size.width - marginsAndInsets) / CGFloat(2)).rounded(.down)
        
        let itemSize = CGSize(width: itemWidth, height: 250)
        
        layout.itemSize = itemSize
        
        let v = UICollectionView(frame: .zero, collectionViewLayout: layout)
        
        v.backgroundColor = UIColor(named: "ListBackground")
        v.delegate = self
        v.dataSource = self
        v.alwaysBounceVertical = true
        v.refreshControl = refreshControl
        v.translatesAutoresizingMaskIntoConstraints = false
        v.register(MovieCell.self, forCellWithReuseIdentifier: "MovieCell")
        
        return v
    }()
    
    private lazy var _viewModel: ListViewModelImpl<NSString, Movie> = {
        
        let mapper = MoviesMapper()
        let service = MoviesCloudService(key: "xxxxxx", hostUrl: "https://www.omdbapi.com/", mapper: mapper)
        let repository = MoviesRepositoryImpl<NSString>(service: service)
        let useCase = UseCaseImpl<NSString, NSArray>(repository: repository)
        
        let viewModel = ListViewModelImpl<NSString, Movie>(useCase: useCase, mapper: nil)
        
        return viewModel
    }()
    
    private lazy var _binding: ViewModelBinding = {
        return ViewModelBinding()
    }()
    
    deinit {
        _binding.dispose()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        binding()
        
        view.addSubview(collectionView)
        
        NSLayoutConstraint.activate([
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.topAnchor.constraint(equalTo: view.topAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        
        _viewModel.inputs.get(request: "avenger")
    }
    
    // MARK - Selector
    @objc func refresh() {
        _viewModel.inputs.get(request: "avenger")
    }
    
    // MARK - Private
    private func binding() {
        
        _binding.subscribe(observable: _viewModel.outputs.loading) { [weak self] result in
            guard let strongSelf = self, let loading = result as? Bool else { return }
            
            strongSelf._isRefreshing = loading
            
            if loading {
                strongSelf.refreshControl.beginRefreshing()
            } else {
                strongSelf.refreshControl.endRefreshing()
            }
        }
        
        _binding.subscribe(observable: _viewModel.outputs.result) { [weak self] result in
            
            guard let strongSelf = self, let list = result as? [Movie] else { return }
            
            strongSelf._movies = list
            strongSelf.collectionView.reloadData()
        }
    }
}

extension ViewController: UICollectionViewDataSource, UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return _movies?.count ?? 0
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "MovieCell", for: indexPath) as! MovieCell
        
        cell.movie = _movies?[indexPath.row]
        
        return cell
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let bottomEdge = scrollView.contentOffset.y + scrollView.frame.size.height;
        
        if (bottomEdge + 200 >= scrollView.contentSize.height && scrollView.contentOffset.y > 0 && !_isRefreshing) {
            _viewModel.inputs.loadMore(request: "avenger")
        }
    }
}

用于显示电影列表的View Controller

其工作原理。

  1. 过程和Android的主活动差不多,我们设置UIRefreshControlUICollectionView,然后懒惰地初始化View Model。
  2. 如果你仔细研究View Model的泛型,我使用的是Obj-c的NSString而不是Swift的String。直接使用Swift的String会给你一个错误'ListViewModelImpl'要求'String是一个类类型。这是我目前发现的使用generics的限制之一。
  3. 我将nil传入ListViewModelImpl mapper中,直接使用Kotlin Multiplatform项目中的领域模型。
  4. ViewDidLoad里面,我们调用binding()方法订阅到视图模型的输出:加载结果,最后调用viewmodel.input.get开始从API下载电影列表。
  5. 当我们完成下载数据后,我们确保结果是一个电影列表,并将其设置为集合视图的数据源。这是我发现的另一个限制;即使我们已经将Movie设置为ListViewModelImpl的泛型,我们总是得到Any作为返回类型。
  6. 不要忘记确保每次弹出或撤消View Controller时都会调用View Controller的deinit

然后......,我们就完成了!

好长的路啊🤩,希望你没有迷路,顺利地走到最后一段。如果你觉得你已经得到了你想要的东西,你可以在这里停下来。不过就像大家说的那样,一个好的应用应该在网络不好/没有网络连接的情况下总是能给出反馈。如果你还有时间,让我们来看看本教程的最后一部分。

缓存

构建一个好的应用时,缓存是重要的部分之一,我们非常幸运,Kotlin多平台有一个名为sqldelight的库来帮助我们。(github.com/cashapp/sql…)

让我们从添加sqldelight依赖关系到build.gradle开始。

首先打开项目的build gradle,然后在依赖关系中添加一个新的classpath。

ext {
        ktor_version = '1.2.5'
        serialization_version = '0.13.0'
        reactive_version = '1.0.0'
        coroutines_version = '1.3.2'
        kotlin_version = '1.3.50'
        sqldelight_version = '1.2.0'
    }
    
dependencies {
    classpath 'com.android.tools.build:gradle:3.5.1'
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
    classpath "com.squareup.sqldelight:gradle-plugin:$sqldelight_version"

    // NOTE: Do not place your application dependencies here; they belong
    // in the individual module build.gradle files
}

在项目的build.gradle中添加sqldelight类的路径。

现在打开,core的build.gradle,应用插件,在commonMainiOSMain中添加sqldelight

apply plugin: 'kotlin-multiplatform'
apply plugin: 'kotlinx-serialization'
apply plugin: 'com.squareup.sqldelight'

sqldelight插件

sourceSets {
        commonMain {
            dependencies {
                // Cache
                implementation "com.squareup.sqldelight:runtime:$sqldelight_version"

                ...
            }
        }
        iosMain {
            dependencies {
                // Cache
                implementation "com.squareup.sqldelight:ios-driver:$sqldelight_version"

                ...
            }  
        }

sqldelight在core的build.gradle中。

在同一个核心的build.gradle中添加以下脚本。

kotlin {
}

sqldelight {
    MoviesDatabase {
        packageName = "com.adrena.core.sql"
        sourceFolders = ["sqldelight"]
        schemaOutputDirectory = file("src/commonMain/sqldelight/databases")
    }
}

configurations {
...

sqldelight配置

它是如何工作的。

  1. 我们在build.gradle中加入了对sqldelight的支持。
  2. 我们也在core的build.gradle里面设置sqldelight数据库配置。

注意:如果没有指定,默认情况下,sqldelight会使用默认的Database作为名称。

接下来,按照我们之前设置的包名和sourceFolderscommonMain里面创建一个目录。你的目录结构应该是这样的。

sqldelight目录结构在commonMain里面。

现在,让我们添加一个名为Movie.sql的新文件,复制sql脚本,保存在com.adrena.core.sql目录下,然后建立项目。

CREATE TABLE movieRecord (
    _id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    imdbID TEXT NOT NULL,
    year TEXT NOT NULL,
    type TEXT NOT NULL,
    poster TEXT NOT NULL
);

insert:
INSERT INTO movieRecord(title, imdbID, year, type, poster)
VALUES (?, ?, ?, ?, ?);

selectAll:
SELECT * FROM movieRecord;

deleteAll:
DELETE FROM movieRecord;

sql语法

注意:sqldelight会根据我们提供的sql语法自动生成一个kotlin脚本。在这个例子中,你可以通过调用insert()来访问插入查询。

你应该会在Android Studio中看到一个类似于这样的生成的kotlin代码。

生成的sqldelight代码

注意:如果你的目录在构建项目后没有生成,请尝试重启你的Android Studio。

我们继续在data目录下创建cache子目录,并创建一个数据库助手类。

expect fun getDriver(dbName: String): SqlDriver

class DatabaseHelper(
    dbName: String,
    sqlDriver: SqlDriver?) {

    val driver: SqlDriver = sqlDriver ?: getDriver(dbName)
    val database: MoviesDatabase = MoviesDatabase(driver)
}

// AndroidMain
// Save it as DatabaseHelper.kt inside com.adrena.data.cache in androidMain
actual fun getDriver(dbName: String): SqlDriver = throw UninitializedPropertyAccessException("Android SqlDriver must be provided from main app")

// iOSMain
// Save it as DatabaseHelper.kt inside com.adrena.data.cache in iosMain
actual fun getDriver(dbName: String): SqlDriver {
    return NativeSqliteDriver(MoviesDatabase.Schema, dbName)
}

数据库帮助类提供从Android和iOS访问缓存的功能。

它是如何工作的。

  1. 我们先在build.gradle的特定包目录下创建名为Movies.sql的文件,与我们的sqldelight配置相匹配。
  2. sqldelight会自动生成kotlin代码。
  3. 我们创建了DatabaseHelper类来提供Android和iOS对sqldelight的访问。Android应该直接从Android项目本身提供驱动,因为它需要Context作为参数,而iOS将使用Kotlin Native Sqlite提供驱动。

Sql缓存

从创建缓存接口及其实现开始

interface Cache<T> {
    fun insert(model: T)
    fun bulkInsert(list: List<T>)
    fun clear()
    fun selectAll(): List<T>
}

缓存接口

class MovieSqlCache(private val db: DatabaseHelper) : Cache<Movie> {
    override fun insert(model: Movie) {
        db.database.movieQueries.insert(
            model.title,
            model.imdbID,
            model.year,
            model.type,
            model.poster
        )
    }

    override fun bulkInsert(list: List<Movie>) {
        db.database.transaction {
            list.forEach {
                insert(it)
            }
        }
    }

    override fun clear() {
        db.database.movieQueries.deleteAll()
    }

    override fun selectAll(): List<Movie> {
        val records = db.database.movieQueries.selectAll().executeAsList()

        return records.map {
            Movie(it.title, it.imdbID, it.year, it.type, it.poster)
        }
    }
}

电影sql实现

它是如何工作的。

  1. 首先我们创建了缓存接口,作为所有应用缓存的合同。缓存并不总是sql,也可以是一个文件或内存。通过提供一个接口,我们将确保我们的应用程序中的任何其他缓存机制将工作,只要它遵循合同。
  2. 我们设置的实现类很直接,插入sql,从sql读取,并将结果转化为电影列表。

仓库类的修改

现在,让我们修改我们的资源库类,以返回电影列表从缓存,如果可用,或请求从API,如果缓存是空的。

class MoviesRepositoryImpl<R>(
    private val service: Service<R, List<Movie>>,
    private val cache: Cache<Movie>
): Repository<R, List<Movie>> {

    override suspend fun get(request: R?): List<Movie> {
        val cachedList = cache.selectAll()

        return if (cachedList.isNotEmpty()) {
            cachedList
        } else {
            val list = service.execute(request)

            cache.clear()
            cache.bulkInsert(list)

            list
        }
    }
}

如果可用,则从缓存中返回列表,如果不可用,则从API中返回。

它是如何工作的。

  1. First we check if we have cached movie list, 首先我们检查是否有缓存的电影列表 if not, we retrive the list from API and save it to cache. 如果没有,我们从API中重新获取列表并保存到缓存。
  2. 现在我们已经完成了多平台项目的更新,现在是时候更新我们的Android和iOS代码了。

安卓系统

sqldelight添加到Android的build.gradle中。

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    implementation project(':core')

    // Cache
    implementation "com.squareup.sqldelight:android-driver:$sqldelight_version"

    ...
}

sqldelight在Android build.gradle中的实现。

现在打开Main Activity,更新以下代码。

    // This should be singleton
    private val dbHelper: DatabaseHelper by lazy {
        val driver = AndroidSqliteDriver(
            schema = MoviesDatabase.Schema,
            context = this,
            name = "movie.db")

        DatabaseHelper("movie.db", driver)
    }

    private val mViewModel: ListViewModel<String, MovieModel> by lazy {

        val domainMapper = MoviesMapper()

        val service = MoviesCloudService("xxxxxx", "https://www.omdbapi.com/", domainMapper)

        val cache = MovieSqlCache(dbHelper)

        val repository = MoviesRepositoryImpl(service, cache)

        val useCase = UseCaseImpl(repository)

        val modelMapper = MovieModelsMapper()

        ListViewModelImpl(useCase, modelMapper)
    }

用缓存更新视图模型对象

最后,更新你的settings.gradle

include ':android', ':core'

enableFeaturePreview('GRADLE_METADATA')

添加启用功能预览

它是如何工作的。

  1. 我们在Android build.gradle中加入sqldelight的实现。
  2. 我们使用AndroidSqliteDriver设置DatabaseHelper类。请注意,这种方法并不推荐。dbHelper应该是单人的,不应该在每个活动中初始化
  3. 我们更新了我们的视图模型对象,以包含缓存。
  4. 上次我们更新了settings.gradle的enableFeaturePreview('**GRADLE_METADATA**')。不要忘记更新,否则当你尝试构建iOS框架时,gradle会抛出错误。

尝试运行你的Android应用。第一次运行时,它将从OMDB中获取电影列表,下次加载时,它将始终从缓存中加载列表。

iOS系统

运行./gradlew packForXCode并打开你的Xcode项目。打开你的AppDelegate并添加这段代码。

import UIKit
import Core

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?
    var dbHelper: DatabaseHelper!
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        try! setupDatabase()
        
        ...
        window?.makeKeyAndVisible()
        
        return true
    }

    func setupDatabase() throws {
        dbHelper = DatabaseHelper(dbName: "movies.db", sqlDriver: nil)
        
        #if DEBUG
        let databaseURL = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
        
        print("DB PATH: \(databaseURL.path)")
        #endif
    }
}

在AppDelegate中设置数据库助手

接下来,打开你的ViewController,更新视图模型对象。

  private lazy var _viewModel: ListViewModelImpl<NSString, Movie> = {
        let delegate = UIApplication.shared.delegate as! AppDelegate
        
        let mapper = MoviesMapper()
        let service = MoviesCloudService(key: "xxxxxx", hostUrl: "https://www.omdbapi.com/", mapper: mapper)
        let cache = MovieSqlCache(db: delegate.dbHelper)
        let repository = MoviesRepositoryImpl<NSString>(service: service, cache: cache)
        let useCase = UseCaseImpl<NSString, NSArray>(repository: repository)
        
        let viewModel = ListViewModelImpl<NSString, Movie>(useCase: useCase, mapper: nil)
        
        return viewModel
    }()

更新了带有缓存的视图模型对象

试着运行你的iOS项目,它的行为应该和Android一样。

结论

Kotlin多平台可以帮助我们为Android和iOS构建一个业务逻辑,加快开发进程,并有助于减少因为代码库不同而产生的不必要的bug。不过,尝试新事物总是有利有弊。以下是我在编写本教程时发现的一些问题。

弊端

  1. Kotlin多平台项目仍然是实验性的,如果你打算在你的生产中尝试它,预计会有很多重构。
  2. 第三方库对多平台的支持还是不多,不过在创建业务逻辑的时候,其实并不需要很多库:ktor、Reaktive、sqldelight和klock对我来说已经足够了。
  3. 没有LiveData,没有android ViewModel,你必须自己照顾你的对象生命周期。
  4. obj-c/swift互操作还不完善。
  5. 调试很困难,直到现在我还不能调试(通过放置断点)Kotlin多平台项目。

优点

  1. 多平台的单一业务逻辑,大大缩短开发时间。

对我来说,这个单一的优点打败了我上面提到的所有缺点。事实上,我打算在下一个项目中尝试多平台。祝我好运吧😄。

最后,你可以从github上获取代码。

github.com/gotamafandy…


通过( www.DeepL.com/Translator )(免费版)翻译