github:android-social-app
Social app with Kotlin, MVVM clean arch, Coroutine, Room with FTS4, Kotlin Gradle, Data binding, Kotlinx Serialization, Koin, AndroidX, Navigation Arch & Git karma convention used
最近研究kotlin,从网上找到这套代码(android-social-app 以后简称social),这是一套比较进阶的kotlin代码,代码量比较大,使用到了很多新的技术。拿到这样的代码时,也一时无从下手。特别是social的gradle使用的是gradle.kts,上网查了一下才发现是可以使用Kotlin来编写gradle脚本,由于这一下子跨度太大,所以Gradle还不打算短期内进行修改(虽然看起来不是太难,我的想法是先看代码,然后再考虑这个)。
Gradle团队为Gradle开发了一种新的基于Kotlin的构建语言,称之为Gradle Script Kotlin,从
Gradle 3.0
开始支持。也就是说我们可以使用Kotlin来编写Gradle脚本了,当使用Kotlin来编写Gradle脚本的时候一切都变得美好了:
脚本代码可以自动补全了
源码之间可以互相跳转了
插件源码更容易看懂了
支持重构了
按照我学习源码的惯例,首先理清一下代码逻辑,找到一个主入口,如MainActivity,也不需要太多流程,先走通一个为准;其次全部逻辑不变的情况下重新建立新的项目半代码逐渐的迁移过来,最终保证能进行最简单的流程,在此过程中不断的理解代码;再次在我的能运行的简易代码上进行深层次的理解,这样从数据到界面数据流转等全部搞清楚,再增加其他的数据显示就比较简单了;最后通过我自己的理解重新组织架构编写可以使用的代码。而我这次主要是以简单代码能运行后,进行重构部分进行详细说明。
通过AndroidStudio新建项目,然后进行相关的设置。
为了能统一管理引用的类库版本,在根目录下新建ext,用于存放版本信息以及类库信息。为了进一步简化引用,将相关的类库用数组的方式放在一起,这样在引用的时候看起来就比较清晰。
ext { compileVersion = 29 minSdk = 21 targetSdk = 29 version_code = 20200808 //程序版本号 version_name = "2.0.1." buildToolsVersion = "30.0.1"// "29.0.3"// "27.0.3" //Build工具 supportLibVersion = "30.0.1" roomVersion = '2.2.5' roomPaging = '2.1.2' koin_version = "2.1.5" navgraph = "2.2.0" archLib = [ livedata : "androidx.lifecycle:lifecycle-livedata:2.2.0", extensions: "androidx.lifecycle:lifecycle-extensions:2.2.0", ] room = [ room : "androidx.room:room-runtime:${roomVersion}", roomCompiler : "androidx.room:room-compiler:${roomVersion}", roomKtx : "androidx.room:room-ktx:${roomVersion}", roomRxJava : "androidx.room:room-rxjava2:${roomVersion}", roomCoroutine : "androidx.room:room-coroutines:${roomVersion}", pagingRuntime : "androidx.paging:paging-runtime:${roomPaging}", pagingCommonTest : "androidx.paging:paging-common:${roomPaging}", pagingRuntimeKtx : "androidx.paging:paging-runtime-ktx:${roomPaging}", pagingCommonTestKtx: "androidx.paging:paging-common-ktx:${roomPaging}" ] supportLib = [ appcompat : 'androidx.appcompat:appcompat:1.1.0', constraintlayout: 'androidx.constraintlayout:constraintlayout:1.1.3', material : 'com.google.android.material:material:1.0.0', cardView : 'androidx.cardview:cardview:1.0.0', multidex : "androidx.multidex:multidex:2.0.1", recycleView : "androidx.recyclerview:recyclerview:1.1.0", legacy : 'androidx.legacy:legacy-support-v4:1.0.0', FragmentKtx : "androidx.fragment:fragment-ktx:1.1.0", viewModelKtx : "androidx.lifecycle:viemodel-ktx:1.1.0", ] navigation = [ fragment : "androidx.navigation:navigation-fragment:$navgraph", fragmentKtx: "androidx.navigation:navigation-fragment-ktx:$navgraph", ui : "androidx.navigation:navigation-ui:$navgraph", uiKtx : "androidx.navigation:navigation-ui-ktx:$navgraph", ] kotlin = [ kotlinStdlibJdk : "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version", coreKtx : "androidx.core:core-ktx:1.2.0", reflect : "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version", coroutineCore : "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.6", coroutineAndroid : "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.6", serializationRuntime: "org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.20.0", ] anko = [ anko: "org.jetbrains.anko:anko-commons:0.10.5", ] glide_version = "4.9.0" glide = [ glide : "com.github.bumptech.glide:glide:$glide_version", compiler: "com.github.bumptech.glide:compiler:$glide_version", gay : 'jp.wasabeef:glide-transformations:4.0.1', ] koin = [ // Koin AndroidX Scope features koinCore : "org.koin:koin-core:$koin_version", koinCoreExt : "org.koin:koin-core-ext:$koin_version", koinAndroid : "org.koin:koin-android:$koin_version", koinScope : "org.koin:koin-androidx-scope:$koin_version", koinViewModel: "org.koin:koin-androidx-viewmodel:$koin_version", koinFragment : "org.koin:koin-androidx-fragment:$koin_version", koinExt : "org.koin:koin-androidx-ext:$koin_version", ] libRxjavaVersion = "2.2.19" libRxAndroidVersion = '2.1.1' libRxKotlin = "2.4.0" retrofitVersion = '2.6.1' gsonConverterVersion = '2.6.1' okhttpVersion = '4.0.1' rxJava = [ rxjava : "io.reactivex.rxjava2:rxjava:${libRxjavaVersion}", rxandroid : "io.reactivex.rxjava2:rxandroid:${libRxAndroidVersion}", rxKotlin : "io.reactivex.rxjava2:rxkotlin:${libRxKotlin}", retrofit : "com.squareup.retrofit2:retrofit:${retrofitVersion}", gsonConverter : "com.squareup.retrofit2:converter-gson:${gsonConverterVersion}", adapterRxjava2 : "com.squareup.retrofit2:adapter-rxjava2:${retrofitVersion}", converterScalar : "com.squareup.retrofit2:converter-scalars:${retrofitVersion}", okhttp : "com.squareup.okhttp3:okhttp:${okhttpVersion}", okhttpLogger : "com.squareup.okhttp3:logging-interceptor:${okhttpVersion}", converterKotlinSerialization: "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.5.0", coroutineAdapter : "com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2", ] chuckDebug = "com.readystatesoftware.chuck:library:1.1.0" dependencies = [ ] }
说明一下,版本我使用的是就近原则,经常看到有的人是将所有的版本都放在一起,这两样各有优缺点,看个人习惯了。
由于使用Kotlin serialization代码gson,所以必须在根目录下build.gradle
dependencies下添加:
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" //必须加上才行
不然数据无法序列化,前面因为没加上调试了很长一段时间。
apply plugin 在原来的几个下面添加
apply plugin: 'kotlin-kapt' apply plugin :"org.jetbrains.kotlin.plugin.serialization" //必须加上才行
同理serialization是因为不用gson序列化必须加上的。
统一comileSdkVersion和buildToolsVersion,以及minSdkVersion等。
同时增加java8编译相关代码,添加上生成apk时带上版本信息,方便查看
compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = "1.8" } dataBinding { enabled = true } applicationVariants.all { variant -> variant.outputs.all { def fileName = outputFileName.replace(".apk", "-V${defaultConfig.versionName}.apk") outputFileName = fileName } }
最后dependencies中也统一引用,如果有更多的Library时,统一引用就非常方便,这个在后面会详细说明。
//implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" //implementation 'androidx.appcompat:appcompat:1.1.0' //implementation 'androidx.core:core-ktx:1.2.0' //implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation supportLib.appcompat implementation supportLib.constraintlayout implementation kotlin.kotlinStdlibJdk implementation kotlin.coreKtx
如果要修改appcompat版本,就可以一次性修改所有的App和Library。
添加Flavor
flavorDimensions "params" productFlavors { anhuiv2test { buildConfigField "String", "BASE_URL", "\"https://jsonplaceholder.typicode.com/\"" buildConfigField "String", "BASE_IMAGE_URL", "\"https://paper.dropboxstatic.com/static/img/\"" buildConfigField "String", "DEFAULT_IMAGE_URL", "\"https://paper.dropboxstatic.com/static/img/favicon/apple-touch-icon.png\"" } }
为了方便(gradle暂时不是重点,所以直接将引用的类库全部迁移过来)最终结果:
apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' apply plugin :"org.jetbrains.kotlin.plugin.serialization" //必须加上才行 android { compileSdkVersion compileVersion buildToolsVersion buildToolsVersion defaultConfig { applicationId "xyz.wayhua.kivy101" minSdkVersion minSdk targetSdkVersion targetSdk versionCode 1 versionName "1.0." + versionCode multiDexEnabled true testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = "1.8" } dataBinding { enabled = true } //生成apk时带上版本信息,方便查看 applicationVariants.all { variant -> variant.outputs.all { def fileName = outputFileName.replace(".apk", "-V${defaultConfig.versionName}.apk") outputFileName = fileName } } flavorDimensions "params" productFlavors { anhuiv2test { buildConfigField "String", "BASE_URL", "\"https://jsonplaceholder.typicode.com/\"" buildConfigField "String", "BASE_IMAGE_URL", "\"https://paper.dropboxstatic.com/static/img/\"" buildConfigField "String", "DEFAULT_IMAGE_URL", "\"https://paper.dropboxstatic.com/static/img/favicon/apple-touch-icon.png\"" } } } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation kotlin.kotlinStdlibJdk implementation kotlin.coreKtx implementation kotlin.coroutineCore implementation kotlin.coroutineAndroid implementation kotlin.serializationRuntime implementation anko.anko implementation koin.koinAndroid implementation koin.koinExt implementation room.room implementation room.roomKtx implementation room.roomRxJava // 无法编译是要改成kapt kapt room.roomCompiler implementation rxJava.rxjava implementation rxJava.rxandroid implementation rxJava.rxKotlin implementation rxJava.retrofit implementation rxJava.gsonConverter implementation rxJava.adapterRxjava2 implementation rxJava.okhttp implementation rxJava.okhttpLogger implementation rxJava.converterKotlinSerialization implementation rxJava.converterScalar implementation rxJava.coroutineAdapter implementation supportLib.appcompat implementation supportLib.material implementation supportLib.constraintlayout implementation navigation.fragment implementation navigation.ui implementation navigation.uiKtx implementation glide.glide implementation chuckDebug testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' }
暂时还是只针对PostFragment进行重构,由于使用了Clear Arch,尽可能做到每层之间相互独立,所以做为流转的实体分成了多个。首先,在界面上使用的是PostItem,数据传输流转Domain数据使用的是Post(原文是PostModel),而保存到数据库中的是PostEntity,网络访问层使用的是PostResponse。当然在这里PostEntity和PostResponse对PostItem是不可见的。相互关系为PostEntity,PostResponse只和Post有关,而Post只和PostItem相关。
因此从数据的流转我会新建domain目录用于存放域数据,为了查找方便就近原则,ui包用于存放所有的界面相关信息,如ui.main存放所有main相关的内容,如MainActivity以及fragment等,而对应的item(显示界面中使用到的数据,也会出现在databinding中)也就相应的存放在一起。
数据仓库相关目录(data),创建repository目录,用于存放所有repository。同时对数据来源进行封装,这和源码是不一样的。对外只显示repository接口,至于数据是从数据库中来还是网络对前端用户是不可见的,这是和原来的代码最大的区别,不过由于源码使用了koin,会出现相互引用(相当于跨层可见,这个层是我分的,源码里是同层的)。同时由于级别不一样,所以目录级别也就不一样。以前datasource和repository是同级,现在就下降一级。同时将前面domain也移到data下面去。
由于使用了mvvm,所以还有一些基础类就都存放到mvvm目录下。adapter定义了针对RecyclerView.Adapter的公用接口,databinding下主要是针对@BindingAdapter的一些自定义的数据绑定有关的内容。定义了两个viewmodel父类,以及MediatorLiveData的一些子类放在live中。还针对数据传输状态定义了sealed类
有相当多的将一些扩展方法放在ext目录中。现在也没认真去一一个的研究,在后面迁移代码时要使用到的扩展方法基本上都会顺便看一下对应的代码的。
由于使用到了koin,所以将di相关的代码存放到di目录下。这个的具体使用在后面有专门介绍,并且在后面也会有补充。
由于RecycleView相关的辅助类存放到list中
使用Rxjava所以相关的一引起辅助类
如rx,list中的相关信息就直接复制过来,等以后有空再进行具体的细节重构。遇到问题,将其他问题代码也迁移过来,同时能认真阅读下代码。如:报错代码为:view.adapter?.itemCount.default
要迁移DefaultExt.kt代码:
package xyz.wayhua.kivy101.ext import xyz.wayhua.kivy101.data.domain.Post /** * * In syaa Allah created or modified by @mochadwi * On 12/05/19 for social-app */ val Boolean?.default: Boolean get() = this ?: false val Int?.default: Int get() = this ?: 0 val Double?.default: Double get() = this ?: 0.0 val Float?.default: Float get() = this ?: 0F val String?.default: String get() = this ?: "" valArrayList ?.default: ArrayList get() = this ?: arrayListOf() val Post?.default: Post get() = this ?: Post()
获取默认值,看上面代码,常用类型为空给出默认值,同时Post也给出默认值,为空时直接new一个。这也提示我们,如果有多个Domain数据,如果使用RecycleView,也必须在这里指定默认值,是不是觉得有点关联度太高了。后面还有类似代码。
Koin 是一个用于 Kotlin 的实用型轻量级依赖注入框架,采用纯 Kotlin 编写而成,仅使用功能解析,无代理、无代码生成、无反射
官网:
[koin] https://insert-koin.io/ 官网
具体使用可以参照官网给出的demo
3.2.1 App
onCreate里面运行startKoin
class App : Application() { override fun onCreate() { super.onCreate() startKoin{ androidLogger(level= Level.DEBUG) androidContext(this@App) modules(allModules) } } }
3.2.2 添加module
其中allModules,暂时只能简单的添加一个,如:
val rxModule = module { // Rx Schedulers single { ApplicationSchedulerProvider() as SchedulerProvider } } val allModules = listOf(rxModule )
其他的module,在后面不断添加。
3.3.1 添加repository
interface AppRepository { fun getPostsAsync(): Deferred?> fun searchPostsAsync(query: String): Deferred
?> }
前面说过,数据源进行降级,所以在repository下新建三个目录impl,room,remote。
3.3.2 room相关代码迁移
迁移的代码这里的代码主要是数据库相关的,如database,dao,实体类PostEntity等,还有Converters,暂时只转换了时间。查看数据库才发现里面还有彩蛋,PostEntity还使用了Fts。
FTS3和FTS4是SQLite虚拟表模块,允许对一组文档执行全文搜索。
可以进行全文搜索,以后有机会一定要好好研究一下代码的使用。
3.3.3 remote 相关代码
这个就相对来说比较简单,只有两个类,一个是网络访问中使用的PostResponse,另一个Retrofit中使用的接口,我修改为IService。
@Serializable data class PostResponse( val userId: Int = 0, // 10 val id: Int = 0, // 100 val title: String = "", // at nam consequatur ea labore ea harum val body: String = "" // cupiditate quo est a modi nesciunt solutaipsa voluptas error itaque dicta inautem qui minus magnam et distinctio eumaccusamus ratione error aut )
网络访问要进行序列化和反序列化,所以要加上@Serializable注解。
interface IService { @GET("posts") fun getPostsAsync(): Deferred> }
由于使用了retrofit,所以就一直会有一个疑问,为什么只使用IService呢?肯定会有配置retrofit的代码,到底怎么写的呢?这个暂时做为悬念留在这。
3.3.4 impl
AppRepository的具体实现,这个代码基本上没有变化。
package xyz.wayhua.kivy101.data.repository.impl import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import xyz.wayhua.kivy101.data.domain.Post import xyz.wayhua.kivy101.data.repository.AppRepository import xyz.wayhua.kivy101.data.repository.remote.IService import xyz.wayhua.kivy101.data.repository.room.PostDao import xyz.wayhua.kivy101.data.repository.room.PostEntity import xyz.wayhua.kivy101.ext.coroutineAsync import xyz.wayhua.kivy101.ext.default import xyz.wayhua.kivy101.ext.sameContentWith class AppRepositoryImpl( private val appWebDatasource: IService, private val postDao: PostDao ) : AppRepository { override fun getPostsAsync(): Deferred?> = coroutineAsync(Dispatchers.IO) { val local = localGetPostsAsync().await() ?: emptyList() val remote = remoteGetPostsAsync().await() ?: emptyList() if ((local sameContentWith remote).default) local else remote } private fun localGetPostsAsync(): Deferred
?> = coroutineAsync(Dispatchers.IO) { postDao.getAllPosts().map { Post.from(it) } } private fun remoteGetPostsAsync(): Deferred
?> = coroutineAsync(Dispatchers.IO) { val result = appWebDatasource.getPostsAsync().await() result.map { postDao.upsert(PostEntity.from(it)) Post.from(it) } } override fun searchPostsAsync(query: String): Deferred
?> = coroutineAsync( Dispatchers.IO ) { postDao.searchPosts(query).map { Post.from(it) } } }
3.3.5 module配置
现在需要配置两套数据源的module,room的数据源module和remote的数据源module
val roomModule = module { // Room Database single { Room.databaseBuilder(androidApplication(), AppDatabase::class.java, "db_app") .fallbackToDestructiveMigration() .build() } // Expose Dao directly single { get().postDao() } }
数据库相关配置是通过koin配置在这里的,同时指定了dao文件。
前面说的远程访问的的问题就在这里,如果对Retrofit比较熟悉看以下代码就相对来说比较简单。
val remoteDatasourceModule = module { // provided web components single { ChuckInterceptor(androidApplication()) } single { HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } } single { createOkHttpClient(get(), get ()) } // Fill property single { createWebService (get(), BASE_URL) } } fun createOkHttpClient(vararg interceptors: Interceptor): OkHttpClient { return OkHttpClient.Builder() .connectTimeout(60L, TimeUnit.SECONDS) .readTimeout(60L, TimeUnit.SECONDS) .apply { if (BuildConfig.DEBUG) { for (intercept in interceptors) { addInterceptor(intercept) } } } .build() } inline fun createWebService(okHttpClient: OkHttpClient, url: String): T { val contentType = "application/json".toMediaTypeOrNull()!! val factory = Json.asConverterFactory(contentType) val retrofit = Retrofit.Builder() .baseUrl(url) .client(okHttpClient) .addConverterFactory(ScalarsConverterFactory.create()) .addConverterFactory(factory) .addCallAdapterFactory(CoroutineCallAdapterFactory()) .build() return retrofit.create(T::class.java) }
还要创建appRepository的module(至于为什么分这么多module,而不是放一起,暂时还没研究那么细)。
val repoModule = module { // App Data Repository single { AppRepositoryImpl(get(), get()) as AppRepository } } val allModules = listOf(rxModule, roomModule, remoteDatasourceModule, repoModule)
到此为止,数据配置相关的代码基本上完成。代码也可以正常运行。
代码参照: KivyV1.0.1 数据部分全部完成