├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── laohu │ │ └── coroutines │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── laohu │ │ │ └── coroutines │ │ │ ├── MainActivity.kt │ │ │ ├── base │ │ │ ├── BasePresenter.kt │ │ │ ├── MvpPresenter.kt │ │ │ └── MvpView.kt │ │ │ ├── model │ │ │ ├── ApiSource.kt │ │ │ └── repository │ │ │ │ └── Repository.kt │ │ │ └── pojo │ │ │ └── GankResult.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ │ └── xml │ │ └── network_security_config.xml │ └── test │ └── java │ └── com │ └── laohu │ └── coroutines │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 在`Android`应用中使用`Kotlin Coroutine(协程)`和`Retrofit`发起网络请求使用`Demo`,主要包含如下东西 2 | 1. 如何将`Kotlin Coroutine(协程)`和`Retrofit`结合使用 3 | 2. 如何在`Kotlin Coroutine(协程)`切换协程所在线程 4 | 3. 如何在`Kotlin Coroutine(协程)`中将两个请求结果进行合并 5 | 4. `Kotlin Coroutine(协程)`中如何实现并发请求 6 | 5. `MVP`开发模式中如何在`Presenter`生命周期结束时优雅的取消协程 7 | 6. 如何将一个普通异步操作改造为协程中的挂起函数 -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | apply plugin: 'kotlin-android' 4 | 5 | apply plugin: 'kotlin-android-extensions' 6 | 7 | android { 8 | compileSdkVersion 28 9 | defaultConfig { 10 | applicationId "com.laohu.coroutines" 11 | minSdkVersion 21 12 | targetSdkVersion 28 13 | versionCode 1 14 | versionName "1.0" 15 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 16 | } 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | 24 | compileOptions { 25 | sourceCompatibility JavaVersion.VERSION_1_8 26 | targetCompatibility JavaVersion.VERSION_1_8 27 | } 28 | } 29 | 30 | dependencies { 31 | implementation fileTree(include: ['*.jar'], dir: 'libs') 32 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 33 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.1.1' 34 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1' 35 | implementation 'com.android.support:appcompat-v7:28.0.0' 36 | implementation 'com.android.support.constraint:constraint-layout:1.1.3' 37 | testImplementation 'junit:junit:4.12' 38 | androidTestImplementation 'com.android.support.test:runner:1.0.2' 39 | androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' 40 | implementation 'com.squareup.retrofit2:retrofit:2.6.0' 41 | implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2' 42 | implementation 'com.squareup.okhttp3:okhttp:3.14.0' 43 | implementation 'com.squareup.retrofit2:converter-gson:2.6.0' 44 | } 45 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/laohu/coroutines/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.laohu.coroutines 2 | 3 | import android.support.test.InstrumentationRegistry 4 | import android.support.test.runner.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getTargetContext() 22 | assertEquals("com.laohu.coroutines", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/laohu/coroutines/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.laohu.coroutines 2 | 3 | import android.support.v7.app.AppCompatActivity 4 | import android.os.Bundle 5 | import android.util.Log 6 | import android.view.View 7 | import android.widget.Toast 8 | import com.laohu.coroutines.base.BasePresenter 9 | import com.laohu.coroutines.base.MvpPresenter 10 | import com.laohu.coroutines.base.MvpView 11 | import com.laohu.coroutines.model.repository.Repository 12 | import com.laohu.coroutines.model.repository.TAG 13 | import com.laohu.coroutines.pojo.Gank 14 | import kotlinx.android.synthetic.main.activity_main.* 15 | import kotlinx.coroutines.launch 16 | 17 | class MainContract { 18 | interface View: MvpView { 19 | fun showLoadingView() 20 | fun showLoadingSuccessView(granks: List) 21 | fun showLoadingErrorView() 22 | } 23 | 24 | interface Presenter: MvpPresenter { 25 | fun syncWithContext() 26 | fun syncNoneWithContext() 27 | fun asyncWithContextForAwait() 28 | fun asyncWithContextForNoAwait() 29 | fun adapterCoroutineQuery() 30 | fun retrofitCoroutine() 31 | } 32 | } 33 | 34 | class MainPresenter: MainContract.Presenter, BasePresenter() { 35 | 36 | override fun syncWithContext() { 37 | presenterScope.launch { 38 | val time = System.currentTimeMillis() 39 | view.showLoadingView() 40 | try { 41 | val ganks = Repository.querySyncWithContext() 42 | view.showLoadingSuccessView(ganks) 43 | } catch (e: Throwable) { 44 | e.printStackTrace() 45 | view.showLoadingErrorView() 46 | } finally { 47 | Log.d(TAG, "耗时:${System.currentTimeMillis() - time}") 48 | } 49 | } 50 | } 51 | 52 | override fun syncNoneWithContext() { 53 | presenterScope.launch { 54 | val time = System.currentTimeMillis() 55 | view.showLoadingView() 56 | try { 57 | val ganks = Repository.querySyncNoneWithContext() 58 | view.showLoadingSuccessView(ganks) 59 | } catch (e: Throwable) { 60 | e.printStackTrace() 61 | view.showLoadingErrorView() 62 | } finally { 63 | Log.d(TAG, "耗时:${System.currentTimeMillis() - time}") 64 | } 65 | } 66 | } 67 | 68 | override fun asyncWithContextForAwait() { 69 | presenterScope.launch { 70 | val time = System.currentTimeMillis() 71 | view.showLoadingView() 72 | try { 73 | val ganks = Repository.queryAsyncWithContextForAwait() 74 | view.showLoadingSuccessView(ganks) 75 | } catch (e: Throwable) { 76 | e.printStackTrace() 77 | Log.d(TAG, "error: ${e.message}") 78 | view.showLoadingErrorView() 79 | } finally { 80 | Log.d(TAG, "耗时:${System.currentTimeMillis() - time}") 81 | } 82 | } 83 | } 84 | 85 | override fun asyncWithContextForNoAwait() { 86 | presenterScope.launch { 87 | val time = System.currentTimeMillis() 88 | view.showLoadingView() 89 | try { 90 | val ganks = Repository.queryAsyncWithContextForNoAwait() 91 | view.showLoadingSuccessView(ganks) 92 | } catch (e: Throwable) { 93 | e.printStackTrace() 94 | view.showLoadingErrorView() 95 | } finally { 96 | Log.d(TAG, "耗时:${System.currentTimeMillis() - time}") 97 | } 98 | } 99 | } 100 | 101 | override fun adapterCoroutineQuery() { 102 | presenterScope.launch { 103 | val time = System.currentTimeMillis() 104 | view.showLoadingView() 105 | try { 106 | val ganks = Repository.adapterCoroutineQuery() 107 | view.showLoadingSuccessView(ganks) 108 | } catch (e: Throwable) { 109 | e.printStackTrace() 110 | view.showLoadingErrorView() 111 | } finally { 112 | Log.d(TAG, "耗时:${System.currentTimeMillis() - time}") 113 | } 114 | } 115 | } 116 | 117 | override fun retrofitCoroutine() { 118 | presenterScope.launch { 119 | val time = System.currentTimeMillis() 120 | view.showLoadingView() 121 | try { 122 | val ganks = Repository.retrofitSuspendQuery() 123 | view.showLoadingSuccessView(ganks) 124 | } catch (e: Throwable) { 125 | e.printStackTrace() 126 | view.showLoadingErrorView() 127 | } finally { 128 | Log.d(TAG, "耗时:${System.currentTimeMillis() - time}") 129 | } 130 | } 131 | } 132 | } 133 | 134 | class MainActivity : AppCompatActivity(), MainContract.View { 135 | private val presenter = MainPresenter() 136 | 137 | override fun onCreate(savedInstanceState: Bundle?) { 138 | super.onCreate(savedInstanceState) 139 | setContentView(R.layout.activity_main) 140 | presenter.attachView(this) 141 | syncWithContextBtn.setOnClickListener { 142 | presenter.syncWithContext() 143 | } 144 | syncNoneWithContext.setOnClickListener { 145 | presenter.syncNoneWithContext() 146 | } 147 | asyncWithContextForAwait.setOnClickListener { 148 | presenter.asyncWithContextForAwait() 149 | } 150 | asyncWithContextForNoAwait.setOnClickListener { 151 | presenter.asyncWithContextForNoAwait() 152 | } 153 | adapterBtn.setOnClickListener { 154 | presenter.adapterCoroutineQuery() 155 | } 156 | retrofitBtn.setOnClickListener { 157 | presenter.retrofitCoroutine() 158 | } 159 | cancelBtn.setOnClickListener { 160 | presenter.detachView() 161 | } 162 | } 163 | 164 | override fun showLoadingView() { 165 | loadingBar.showSelf() 166 | } 167 | 168 | override fun showLoadingSuccessView(granks: List) { 169 | loadingBar.hideSelf() 170 | textView.text = "请求结束,数据条数:${granks.size}" 171 | Toast.makeText(this, "加载成功", Toast.LENGTH_SHORT).show() 172 | Log.d(TAG, "请求结果:$granks") 173 | } 174 | 175 | override fun showLoadingErrorView() { 176 | loadingBar.hideSelf() 177 | Toast.makeText(this, "加载失败", Toast.LENGTH_SHORT).show() 178 | } 179 | 180 | override fun onDestroy() { 181 | super.onDestroy() 182 | presenter.detachView() 183 | } 184 | 185 | override fun onBackPressed() { 186 | finish() 187 | } 188 | } 189 | 190 | fun View.showSelf() { 191 | this.visibility = View.VISIBLE 192 | } 193 | 194 | fun View.hideSelf() { 195 | this.visibility = View.GONE 196 | } 197 | -------------------------------------------------------------------------------- /app/src/main/java/com/laohu/coroutines/base/BasePresenter.kt: -------------------------------------------------------------------------------- 1 | package com.laohu.coroutines.base 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.coroutines.Dispatchers 5 | 6 | open class BasePresenter : MvpPresenter { 7 | lateinit var view: V 8 | val presenterScope: CoroutineScope by lazy { 9 | CoroutineScope(Dispatchers.Main + Job()) 10 | } 11 | 12 | override fun attachView(view: V) { 13 | this.view = view 14 | } 15 | 16 | override fun detachView() { 17 | presenterScope.cancel() 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/laohu/coroutines/base/MvpPresenter.kt: -------------------------------------------------------------------------------- 1 | package com.laohu.coroutines.base 2 | 3 | import android.support.annotation.UiThread 4 | 5 | interface MvpPresenter { 6 | 7 | @UiThread 8 | fun attachView(view: V) 9 | 10 | @UiThread 11 | fun detachView() 12 | } -------------------------------------------------------------------------------- /app/src/main/java/com/laohu/coroutines/base/MvpView.kt: -------------------------------------------------------------------------------- 1 | package com.laohu.coroutines.base 2 | 3 | interface MvpView -------------------------------------------------------------------------------- /app/src/main/java/com/laohu/coroutines/model/ApiSource.kt: -------------------------------------------------------------------------------- 1 | package com.laohu.coroutines.model 2 | 3 | import android.util.Log 4 | import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory 5 | import com.laohu.coroutines.model.repository.TAG 6 | import com.laohu.coroutines.pojo.GankResult 7 | import kotlinx.coroutines.Deferred 8 | import kotlinx.coroutines.suspendCancellableCoroutine 9 | import retrofit2.Call 10 | import retrofit2.Callback 11 | import retrofit2.Response 12 | import retrofit2.Retrofit 13 | import retrofit2.converter.gson.GsonConverterFactory 14 | import retrofit2.http.GET 15 | import kotlin.coroutines.resume 16 | import kotlin.coroutines.resumeWithException 17 | import kotlin.coroutines.suspendCoroutine 18 | 19 | interface CallAdapterApiService { 20 | @GET("data/iOS/2/1") 21 | fun getIOSGank(): Deferred 22 | 23 | @GET("data/Android/2/1") 24 | fun getAndroidGank(): Deferred 25 | } 26 | 27 | interface ApiService { 28 | @GET("data/iOS/2/1") 29 | fun getIOSGank(): Call 30 | 31 | @GET("data/Android/2/1") 32 | fun getAndroidGank(): Call 33 | 34 | @GET("data/Android/2/1") 35 | suspend fun getSuspendAndroidGank(): GankResult 36 | 37 | @GET("data/iOS/2/1") 38 | suspend fun getSuspendIOSGank(): GankResult 39 | } 40 | 41 | class ApiSource { 42 | companion object { 43 | @JvmField 44 | val callAdapterInstance = Retrofit.Builder() 45 | .baseUrl("http://gank.io/api/") 46 | .addCallAdapterFactory(CoroutineCallAdapterFactory()) 47 | .addConverterFactory(GsonConverterFactory.create()) 48 | .build().create(CallAdapterApiService::class.java) 49 | 50 | @JvmField 51 | val instance = Retrofit.Builder() 52 | .baseUrl("http://gank.io/api/") 53 | .addConverterFactory(GsonConverterFactory.create()) 54 | .build().create(ApiService::class.java) 55 | } 56 | } 57 | 58 | suspend fun Call.await(): T { 59 | return suspendCancellableCoroutine { 60 | it.invokeOnCancellation { 61 | Log.d(TAG, "request cancel") 62 | it?.printStackTrace() 63 | cancel() 64 | } 65 | enqueue(object : Callback { 66 | override fun onFailure(call: Call, t: Throwable) { 67 | it.resumeWithException(t) 68 | } 69 | 70 | override fun onResponse(call: Call, response: Response) { 71 | if(response.isSuccessful) { 72 | it.resume(response.body()!!) 73 | } else{ 74 | it.resumeWithException(Throwable(response.toString())) 75 | } 76 | } 77 | }) 78 | } 79 | } -------------------------------------------------------------------------------- /app/src/main/java/com/laohu/coroutines/model/repository/Repository.kt: -------------------------------------------------------------------------------- 1 | package com.laohu.coroutines.model.repository 2 | 3 | import com.laohu.coroutines.model.ApiSource 4 | import com.laohu.coroutines.model.await 5 | import com.laohu.coroutines.pojo.Gank 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.async 8 | import kotlinx.coroutines.delay 9 | import kotlinx.coroutines.withContext 10 | 11 | const val TAG = "TestCoroutine" 12 | object Repository { 13 | 14 | /** 15 | * 两个请求在子线程中顺序执行,非同时并发 16 | */ 17 | suspend fun querySyncWithContext(): List { 18 | return withContext(Dispatchers.Main) { 19 | try { 20 | val androidResult = ApiSource.instance.getAndroidGank().await() 21 | 22 | val iosResult = ApiSource.instance.getIOSGank().await() 23 | 24 | val result = mutableListOf().apply { 25 | addAll(iosResult.results) 26 | addAll(androidResult.results) 27 | } 28 | result 29 | } catch (e: Throwable) { 30 | e.printStackTrace() 31 | throw e 32 | } 33 | } 34 | } 35 | 36 | /** 37 | * 两个请求在主线程中顺序执行,非同时并发 38 | */ 39 | suspend fun querySyncNoneWithContext(): List { 40 | return try { 41 | val androidResult = ApiSource.instance.getAndroidGank().await() 42 | 43 | val iosResult = ApiSource.instance.getIOSGank().await() 44 | 45 | val result = mutableListOf().apply { 46 | addAll(iosResult.results) 47 | addAll(androidResult.results) 48 | } 49 | result 50 | } catch (e: Throwable) { 51 | e.printStackTrace() 52 | throw e 53 | } 54 | } 55 | 56 | /** 57 | * 两个请求在子线程中并发执行 58 | */ 59 | suspend fun queryAsyncWithContextForAwait(): List { 60 | return withContext(Dispatchers.Main) { 61 | try { 62 | val androidDeferred = async { 63 | val androidResult = ApiSource.instance.getAndroidGank().await() 64 | androidResult 65 | } 66 | 67 | val iosDeferred = async { 68 | val iosResult = ApiSource.instance.getIOSGank().await() 69 | iosResult 70 | } 71 | 72 | val androidResult = androidDeferred.await().results 73 | val iosResult = iosDeferred.await().results 74 | 75 | val result = mutableListOf().apply { 76 | addAll(iosResult) 77 | addAll(androidResult) 78 | } 79 | result 80 | } catch (e: Throwable) { 81 | e.printStackTrace() 82 | throw e 83 | } 84 | } 85 | } 86 | 87 | /** 88 | * 两个请求在子线程中并发执行 89 | */ 90 | suspend fun queryAsyncWithContextForNoAwait(): List { 91 | return withContext(Dispatchers.IO) { 92 | try { 93 | val androidDeferred = async { 94 | val androidResult = ApiSource.instance.getAndroidGank().execute() 95 | if(androidResult.isSuccessful) { 96 | androidResult.body()!! 97 | } else { 98 | throw Throwable("android request failure") 99 | } 100 | } 101 | 102 | val iosDeferred = async { 103 | val iosResult = ApiSource.instance.getIOSGank().execute() 104 | if(iosResult.isSuccessful) { 105 | iosResult.body()!! 106 | } else { 107 | throw Throwable("ios request failure") 108 | } 109 | } 110 | 111 | val androidResult = androidDeferred.await().results 112 | val iosResult = iosDeferred.await().results 113 | 114 | val result = mutableListOf().apply { 115 | addAll(iosResult) 116 | addAll(androidResult) 117 | } 118 | result 119 | } catch (e: Throwable) { 120 | e.printStackTrace() 121 | throw e 122 | } 123 | } 124 | } 125 | 126 | suspend fun adapterCoroutineQuery(): List { 127 | return withContext(Dispatchers.Main) { 128 | try { 129 | val androidDeferred = ApiSource.callAdapterInstance.getAndroidGank() 130 | 131 | val iosDeferred = ApiSource.callAdapterInstance.getIOSGank() 132 | 133 | val androidResult = androidDeferred.await().results 134 | 135 | val iosResult = iosDeferred.await().results 136 | 137 | val result = mutableListOf().apply { 138 | addAll(iosResult) 139 | addAll(androidResult) 140 | } 141 | result 142 | } catch (e: Throwable) { 143 | e.printStackTrace() 144 | throw e 145 | } 146 | } 147 | } 148 | 149 | suspend fun retrofitSuspendQuery(): List { 150 | return withContext(Dispatchers.Main) { 151 | try { 152 | val androidResult = ApiSource.instance.getSuspendAndroidGank() 153 | val iosResult = ApiSource.instance.getSuspendIOSGank() 154 | mutableListOf().apply { 155 | addAll(iosResult.results) 156 | addAll(androidResult.results) 157 | } 158 | } catch (e: Throwable) { 159 | throw e 160 | } 161 | } 162 | } 163 | } -------------------------------------------------------------------------------- /app/src/main/java/com/laohu/coroutines/pojo/GankResult.kt: -------------------------------------------------------------------------------- 1 | package com.laohu.coroutines.pojo 2 | 3 | data class Gank( 4 | val _id: String, 5 | val createdAt: String, 6 | val desc: String, 7 | val publishedAt: String, 8 | val source: String, 9 | val type: String, 10 | val url: String, 11 | val used: Boolean, 12 | val who: String 13 | ) 14 | 15 | data class GankResult( 16 | val error: Boolean, 17 | val results: List 18 | ) -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 54 | 56 | 58 | 60 | 62 | 64 | 66 | 68 | 70 | 72 | 74 | 75 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 |