HTTP Client

在 Android 开发如果要用到 HTTP client 的话基本上大家都预设用 OkHttp + Retrofit 这个组合。这次我们试试一些新东西:Ktor

Ktor 是 JetBrains 出的 server library,就是用来开发 server side 的 web application。但它的功能比较简单。不过我们不是用它的 server library,是用它的 client library。近年来 Kotlin 推广用 Kotlin 写跨平台应用(网页、Android、iOS、backend),在 mobile app 那边叫 Kotlin Multiplatform Mobile (KMM),它就是要用 Kotlin 来写 Android 和 iOS 共用的部分(通常就是 business logic、接驳 backend 那部分),至於 UI 的部分就各自用回该平台的方法写。正因为共通的部分必须要用纯 Kotlin 来写,code 不能引用 Java Standard Library 的东西,所以 OkHttp 和 Retrofit 就不能直接在 KMM 上面用,取而代之就是 Ktor Client。

因应不同平台实际处理 HTTP request 的 client(Ktor 称为 engine)各有不同,Ktor 把这些 HTTP client 封装了一次。例如在 Android 可以用 OkHttp、CIO,在 iOS 就是用 NSURLSession。所以在建立 Ktor client 时要因应不同平台有不同的设定,但你调用 Ktor 的地方就不用加那些 if (Android) { ... } 的东西。

以下是我们这次会用到的 dependency:

implementation "io.ktor:ktor-client-core:$ktorVersion"
implementation "io.ktor:ktor-client-okhttp:$ktorVersion"
implementation "io.ktor:ktor-client-logging:$ktorVersion"
implementation "io.ktor:ktor-client-serialization:$ktorVersion"
testImplementation "io.ktor:ktor-client-mock:$ktorVersion"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"

implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion"

// logging the HTTP request and response
implementation "org.slf4j:slf4j-api:$slf4jVersion"
implementation "com.github.tony19:logback-android:$logbackAndroidVersion"

Ktor Client 基本用法

如果我们甚麽都不理,只是单纯用 Ktor client call API 的话,大概会是这样:

val httpClient = HttpClient(OkHttp) {
    expectSuccess = true
    install(Logging) {
        logger = Logger.DEFAULT
        level = LogLevel.ALL
    }
    install(JsonFeature) {
        serializer = KotlinxSerializer(kotlinx.serialization.json.Json {
            coerceInputValues = true
            ignoreUnknownKeys = true
        })
    }
}
val response: EtaResponse = httpClient.get<EtaResponse>("https://rt.data.gov.hk/v1/transport/mtr/getSchedule.php?line=TML&sta=TIS&lang=TC")

设定 Ktor client 的写法用了很多 lambda,就像那些 build.gradle 般做了专门而设的 DSL。上面的 HTTP client 设定就是帮它加了 logging 和用 Kotlin Serialization 做 JSON deserialization。

然後发送 HTTP request 就是简单一句 httpClient.get<EtaResponse> 就能拿到 deserialize 好的 response data class object。

如果见到网址有一大串 query parameter 感觉不爽的话,可以写成这样:

httpClient.get<EtaResponse>("https://rt.data.gov.hk/v1/transport/mtr/getSchedule.php") {
    parameter("line", "TML")
    parameter("sta", "TIS")
    parameter("lang", "TC")
}

这个 httpClient.get 是 suspending function,IDE 会在 suspending function 的行数显示 gutter icon https://ithelp.ithome.com.tw/upload/images/20210921/20139666SCeYh83KDe.png 作提示。Suspending function 要有 Coroutine scope 包住才能用,以 Activity 为例,你不能在 onCreate 内 call 这一句,因为 onCreate 不是 suspending function,只有在 suspending function 内才能 call 另一个 suspending function,或者是在 coroutine scope 内。简单来讲,coroutine scope 就是用来连接 coroutine 和非 coroutine 的地方,coroutine scope 另一个用途是用来一并停止未完结的 suspending function,例如 onDestroy 时就可以 call coroutine scope 的 cancel。这个意念跟 RxJava 的 CompositeDisposable 类似。其实现在 AndroidX 的 library 已经帮我们在 ActivityFragmentViewModel 等地方为我们造好了对应其 lifecycle 的 coroutine scope,我们只需要直接调用就可以了,详细的内容我们之後会示范。

Dagger 设定

看过基本用法後我们要把 Ktor client 的设置放到 Dagger module 入面,这样就可以经 Dagger 取得 Ktor client 的 instance。以下是大约的写法:

@Module
@InstallIn(SingletonComponent::class)
interface DataModule {

    @BindsOptionalOf
    fun bindLogging(): HttpClientFeature<Logging.Config, Logging>

    companion object {
        @Provides
        @Singleton
        fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
            .build()

        @Provides
        fun provideHttpClientEngine(okHttpClient: OkHttpClient): HttpClientEngine = OkHttp.create {
            preconfigured = okHttpClient
        }

        @Provides
        fun provideLogging(): HttpClientFeature<Logging.Config, Logging> = Logging.apply {
            prepare {
                logger = Logger.DEFAULT
                level = LogLevel.ALL
            }
        }

        @Provides
        @Singleton
        fun provideKtorHttpClient(
            engine: HttpClientEngine,
            logging: Optional<HttpClientFeature<Logging.Config, Logging>>,
        ): HttpClient = HttpClient(engine) {
            expectSuccess = true
            logging.ifPresent { install(it) }
            install(JsonFeature) {
                serializer = KotlinxSerializer(kotlinx.serialization.json.Json {
                    coerceInputValues = true
                    ignoreUnknownKeys = true
                })
            }
        }
    }
}

Dagger module 是用来向 Dagger 提供一些 Dagger 未能自动 instantiate 的 object。如果你有看过 Dagger 的教学,都是要在 class 的 constructor 加上 @Inject 然後在执行时那些写在 constructor 的 parameter 就会自然地取得那些 object。这个自动找到 dependency 塞入去 constructor 给你用的动作就是 Dagger 帮你做的,但它那个自动功能只能 inject 其他在 constructor 加了 @Inject 的 class。但遇到其他 third party 的 class 例如 Ktor client 又或者是 Android SDK 入面的 ConnectivityManager 之类就要靠我们自己写 Dagger module 来提示 Dagger 如何 instantiate 这些 class。@Provides 就是用来手动教 Dagger 如何 instantiate 那个 object。@Provides 的 function 名是不重要,因为 Dagger 只看 parameter type 和 return type,但习惯上都是会跟 annotation 名作前缀。@Provides function parameter 就是用来取得其他 dependency,例如 provideKtorHttpClient 需要用到 HttpClientEngineHttpClientFeature<Logging.Config, Logging> 来 instantiate HttpClient

你或许会留意到第二个 parameter 被 Optional 包住,这个 Optional 是 Java 8 的东西,就是表示 HttpClientFeature<Logging.Config, Logging> 可能会有亦可能会无,有点像 nullable 的意思。因为那个被加注 @BindsOptionalOf 的 function,Dagger 能看懂 Optional。如果你全个 app 都没有 @Provides 那个 logging feature 它亦不会 build fail。我把 logging 包了 Optional 是因为在 testing 或在 release build 时我们就不用为 HttpClient 加 logging。

在 module 除了看到 @Module 之外,还有 @InstallIn(SingletonComponent::class),这个 annotation 是 Hilt 的东西。Hilt 就是帮你订好一个 Android app 会有那些 component。Component 主要作用是用来控制那些由 Dagger inject 的 dependency object 是不是在某范围内重用还是每次要用到那个 dependency 都去 instantiate 一个新的。Hilt 的 SingletonComponent 就是跟 Application 共生死,它有对应的 scope 叫 @Singleton。上面 OkHttpClientHttpClient 都加了 @Singleton,意思是如果那个 SingletonComponent 都是同一个 instance 的话,那我经那个 component 拿到的 OkHttpClientHttpClient 都会是同一个 instance。正因为 Application 在执行时只会有一个 instance,所以 OkHttpClientHttpClient 就变相成为平时我们理解的 singleton 一样,只是不是用 object class 而是靠 Dagger 控制。而 OkHttpClientHttpClient 要设成一个 app 共用同一个 instance 是因为 HTTP client 和 SQLite database connection 之类的东西建立成本比较高,所以不应每 call 一次 HTTP request 或 database query 都造一个全新的 connection。OkHttpClient 亦有同样的提示:

OkHttp performs best when you create a single OkHttpClient instance and reuse it for all of your HTTP calls. This is because each client holds its own connection pool and thread pools. Reusing connections and threads reduces latency and saves memory. Conversely, creating a client for each request wastes resources on idle pools.

最後有一样东西或许大家有留意到就是 DataModule 是 interface,入面只放 @BindsOptionalOf function(还有日後的 @Binds function),而内里的 companion object 就放了 @Provides 的 function。这是因为按照 Dagger 网站的说明 @Provides 最好是 static 而 @Binds 就因为 Dagger 会生成对应的 code,所以用 interface 就够了。

And for any module whose @Provides methods are all static, the implementation doesn’t need an instance at all.

@Binds methods are a drop-in replacement for Provides methods that simply return an injected parameter. Prefer @Binds because the generated implementation is likely to be more efficient.

Using @Binds is the preferred way to define an alias because Dagger only needs the module at compile time, and can avoid class loading the module at runtime.

我们已经准备好 Ktor client,下一篇我们会写处理 backend API call 的部分。


<<:  Go Concurrency Patterns: Pipelines and cancellation

>>:  GCP 挂载X磁碟X快照

Day 05 - Scanners

本篇重点 Scanners介绍 当日成交金额排行 当日成交量排行 当日涨(跌)金额排行 最高/最低价...

Day 15 放射线背景

放射线背景 教学原文参考:放射线背景 这篇文章会介绍使用 GIMP 的渐层填色,搭配滤镜的「小小星球...

【D15】当大盘涨的时候,跟台积电有关系吗?

前言 取得众多资料後,接下来就要分析,我们来看看台积电与加权指数有关系吗? d15_2330AndT...

[Day11] Esp32s用AP mode + LED

1.前言 讲了那麽多天的理论,现在该来让各位多动手实作啦,今天主要是会运用Esp32s内建的WiFi...

30天程序语言研究

今天是30天程序语言研究的第四天,研究的语言是python,今天主要学习的部分是tuple和func...