├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── zj │ │ └── architecture │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── zj │ │ │ └── architecture │ │ │ ├── MyApp.kt │ │ │ ├── RouterActivity.kt │ │ │ ├── login │ │ │ ├── LoginViewState.kt │ │ │ ├── flow │ │ │ │ ├── FlowLoginActivity.kt │ │ │ │ └── LoginViewModel.kt │ │ │ └── livedata │ │ │ │ ├── LoginActivity.kt │ │ │ │ └── LoginViewModel.kt │ │ │ ├── mainscreen │ │ │ ├── MainActivity.kt │ │ │ ├── MainViewModel.kt │ │ │ ├── MainViewStates.kt │ │ │ └── NewsRvAdapter.kt │ │ │ ├── mockapi │ │ │ ├── MockApi.kt │ │ │ ├── MockApiResponse.kt │ │ │ └── MockInterceptor.kt │ │ │ ├── network │ │ │ ├── CoroutineScopeHelper.kt │ │ │ ├── NetworkExceptionHandler.kt │ │ │ └── NetworkExt.kt │ │ │ ├── networkscreen │ │ │ ├── FlowViewModel.kt │ │ │ ├── NetworkActivity.kt │ │ │ └── NetworkViewState.kt │ │ │ ├── repository │ │ │ ├── NewsItem.kt │ │ │ └── NewsRepository.kt │ │ │ └── utils │ │ │ └── AppUtils.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── activity_network.xml │ │ ├── activity_router.xml │ │ ├── item_view.xml │ │ ├── layout_error.xml │ │ ├── layout_loading.xml │ │ └── layout_login.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── bg_error.png │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── com │ └── zj │ └── architecture │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── mvi-core ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── zj │ │ └── mvi │ │ └── core │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ └── java │ │ └── com │ │ └── zj │ │ └── mvi │ │ └── core │ │ ├── LiveEvents.kt │ │ ├── MVIExt.kt │ │ ├── MVIFlowExt.kt │ │ └── SingleLiveEvents.kt │ └── test │ └── java │ └── com │ └── zj │ └── mvi │ └── core │ └── ExampleUnitTest.kt └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | .idea 4 | /local.properties 5 | /.idea/caches 6 | /.idea/libraries 7 | /.idea/modules.xml 8 | /.idea/workspace.xml 9 | /.idea/navEditor.xml 10 | /.idea/assetWizardSettings.xml 11 | .DS_Store 12 | /build 13 | /captures 14 | .externalNativeBuild 15 | .cxx 16 | local.properties 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ricardo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Android架构更佳实践 2 | 本项目基于`MVI`架构,探索`Android`开发架构更佳实践,主要包括以下内容 3 | - [MVVM 进阶版:MVI 架构了解一下~](https://juejin.cn/post/7022624191723601928) 4 | - [MVI 架构更佳实践:支持 LiveData 属性监听](https://juejin.cn/post/7025222741322121223) 5 | - [MVI 架构封装:快速优雅地实现网络请求](https://juejin.cn/post/7027815347281477645) 6 | 7 | ## License 8 | ``` 9 | MIT License 10 | 11 | Copyright (c) 2022 Ricardo 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | ``` -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | id 'kotlin-kapt' 5 | id 'kotlin-android-extensions' 6 | } 7 | 8 | android { 9 | compileSdk 31 10 | 11 | defaultConfig { 12 | applicationId "com.zj.architecture" 13 | minSdk 21 14 | targetSdk 31 15 | versionCode 1 16 | versionName "1.0" 17 | 18 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 19 | } 20 | 21 | buildTypes { 22 | release { 23 | minifyEnabled false 24 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 25 | } 26 | } 27 | compileOptions { 28 | sourceCompatibility JavaVersion.VERSION_1_8 29 | targetCompatibility JavaVersion.VERSION_1_8 30 | } 31 | kotlinOptions { 32 | jvmTarget = '1.8' 33 | } 34 | } 35 | 36 | dependencies { 37 | 38 | implementation 'androidx.core:core-ktx:1.6.0' 39 | implementation 'androidx.appcompat:appcompat:1.3.1' 40 | implementation 'com.google.android.material:material:1.4.0' 41 | implementation 'androidx.constraintlayout:constraintlayout:2.1.1' 42 | implementation("androidx.recyclerview:recyclerview:1.2.1") 43 | implementation("com.google.android.material:material:1.4.0") 44 | implementation("androidx.activity:activity-ktx:1.3.1") 45 | implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") 46 | 47 | //ViewModel and LiveData 48 | implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1") 49 | implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.4.1") 50 | kapt("androidx.lifecycle:lifecycle-common-java8:2.4.1") 51 | 52 | //Coroutines 53 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1") 54 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1") 55 | 56 | //Image Loading 57 | implementation("io.coil-kt:coil:0.8.0") 58 | 59 | //Webservices 60 | implementation("com.squareup.retrofit2:retrofit:2.6.2") 61 | implementation("com.squareup.retrofit2:converter-gson:2.6.2") 62 | implementation 'com.github.liangjingkanji:StateLayout:1.2.0' 63 | implementation(project(":mvi-core")) 64 | } -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /app/src/androidTest/java/com/zj/architecture/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.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.getInstrumentation().targetContext 22 | assertEquals("com.zj.architecture", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 13 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/MyApp.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture 2 | 3 | import android.app.Application 4 | import com.drake.statelayout.StateConfig 5 | 6 | class MyApp : Application() { 7 | 8 | companion object { 9 | lateinit var instance: MyApp 10 | 11 | fun get(): MyApp { 12 | return instance 13 | } 14 | } 15 | 16 | override fun onCreate() { 17 | super.onCreate() 18 | instance = this 19 | StateConfig.apply { 20 | loadingLayout = R.layout.layout_loading 21 | errorLayout = R.layout.layout_error 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/RouterActivity.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.view.View 6 | import androidx.appcompat.app.AppCompatActivity 7 | import com.zj.architecture.login.flow.FlowLoginActivity 8 | import com.zj.architecture.login.livedata.LoginActivity 9 | import com.zj.architecture.mainscreen.MainActivity 10 | import com.zj.architecture.networkscreen.NetworkActivity 11 | 12 | class RouterActivity : AppCompatActivity() { 13 | override fun onCreate(savedInstanceState: Bundle?) { 14 | super.onCreate(savedInstanceState) 15 | setContentView(R.layout.activity_router) 16 | } 17 | 18 | fun simple(view: View) { 19 | startActivity(Intent(this, MainActivity::class.java)) 20 | } 21 | 22 | fun network(view: View) { 23 | startActivity(Intent(this, NetworkActivity::class.java)) 24 | } 25 | 26 | fun login(view: View) { 27 | startActivity(Intent(this, LoginActivity::class.java)) 28 | } 29 | 30 | fun loginFlow(view: View) { 31 | startActivity(Intent(this, FlowLoginActivity::class.java)) 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/login/LoginViewState.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.login 2 | 3 | import com.zj.architecture.networkscreen.NetworkViewEvent 4 | 5 | data class LoginViewState(val userName: String = "", val password: String = "") { 6 | val isLoginEnable: Boolean 7 | get() = userName.isNotEmpty() && password.length >= 6 8 | val passwordTipVisible: Boolean 9 | get() = password.length in 1..5 10 | } 11 | 12 | sealed class LoginViewEvent { 13 | data class ShowToast(val message: String) : LoginViewEvent() 14 | object ShowLoadingDialog : LoginViewEvent() 15 | object DismissLoadingDialog : LoginViewEvent() 16 | } 17 | 18 | sealed class LoginViewAction { 19 | data class UpdateUserName(val userName: String) : LoginViewAction() 20 | data class UpdatePassword(val password: String) : LoginViewAction() 21 | object Login : LoginViewAction() 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/login/flow/FlowLoginActivity.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.login.flow 2 | 3 | import android.app.ProgressDialog 4 | import android.os.Bundle 5 | import android.view.View 6 | import androidx.activity.viewModels 7 | import androidx.appcompat.app.AppCompatActivity 8 | import androidx.core.widget.addTextChangedListener 9 | import com.zj.architecture.R 10 | import com.zj.architecture.login.LoginViewAction 11 | import com.zj.architecture.login.LoginViewEvent 12 | import com.zj.architecture.login.LoginViewState 13 | import com.zj.architecture.utils.toast 14 | import com.zj.mvi.core.observeEvent 15 | import com.zj.mvi.core.observeState 16 | import kotlinx.android.synthetic.main.layout_login.* 17 | 18 | class FlowLoginActivity : AppCompatActivity() { 19 | private val viewModel by viewModels() 20 | override fun onCreate(savedInstanceState: Bundle?) { 21 | super.onCreate(savedInstanceState) 22 | setContentView(R.layout.layout_login) 23 | initView() 24 | initViewStates() 25 | initViewEvents() 26 | } 27 | 28 | private fun initView() { 29 | edit_user_name.addTextChangedListener { 30 | viewModel.dispatch(LoginViewAction.UpdateUserName(it.toString())) 31 | } 32 | edit_password.addTextChangedListener { 33 | viewModel.dispatch(LoginViewAction.UpdatePassword(it.toString())) 34 | } 35 | btn_login.setOnClickListener { 36 | viewModel.dispatch(LoginViewAction.Login) 37 | } 38 | } 39 | 40 | private fun initViewStates() { 41 | viewModel.viewStates.let { states -> 42 | states.observeState(this, LoginViewState::userName) { 43 | edit_user_name.setText(it) 44 | edit_user_name.setSelection(it.length) 45 | } 46 | states.observeState(this, LoginViewState::password) { 47 | edit_password.setText(it) 48 | edit_password.setSelection(it.length) 49 | } 50 | states.observeState(this, LoginViewState::isLoginEnable) { 51 | btn_login.isEnabled = it 52 | btn_login.alpha = if (it) 1f else 0.5f 53 | } 54 | states.observeState(this, LoginViewState::passwordTipVisible) { 55 | tv_label.visibility = if (it) View.VISIBLE else View.INVISIBLE 56 | } 57 | } 58 | } 59 | 60 | private fun initViewEvents() { 61 | viewModel.viewEvents.observeEvent(this) { 62 | when (it) { 63 | is LoginViewEvent.ShowToast -> toast(it.message) 64 | is LoginViewEvent.ShowLoadingDialog -> showLoadingDialog() 65 | is LoginViewEvent.DismissLoadingDialog -> dismissLoadingDialog() 66 | } 67 | } 68 | } 69 | 70 | private var progressDialog: ProgressDialog? = null 71 | 72 | private fun showLoadingDialog() { 73 | if (progressDialog == null) 74 | progressDialog = ProgressDialog(this) 75 | progressDialog?.show() 76 | } 77 | 78 | private fun dismissLoadingDialog() { 79 | progressDialog?.takeIf { it.isShowing }?.dismiss() 80 | } 81 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/login/flow/LoginViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.login.flow 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.zj.architecture.login.LoginViewAction 6 | import com.zj.architecture.login.LoginViewEvent 7 | import com.zj.architecture.login.LoginViewState 8 | import com.zj.mvi.core.SharedFlowEvents 9 | import com.zj.mvi.core.setEvent 10 | import com.zj.mvi.core.setState 11 | import com.zj.mvi.core.withState 12 | import kotlinx.coroutines.delay 13 | import kotlinx.coroutines.flow.* 14 | import kotlinx.coroutines.launch 15 | 16 | class LoginViewModel : ViewModel() { 17 | private val _viewStates = MutableStateFlow(LoginViewState()) 18 | val viewStates = _viewStates.asStateFlow() 19 | private val _viewEvents = SharedFlowEvents() 20 | val viewEvents = _viewEvents.asSharedFlow() 21 | 22 | fun dispatch(viewAction: LoginViewAction) { 23 | when (viewAction) { 24 | is LoginViewAction.UpdateUserName -> updateUserName(viewAction.userName) 25 | is LoginViewAction.UpdatePassword -> updatePassword(viewAction.password) 26 | is LoginViewAction.Login -> login() 27 | } 28 | } 29 | 30 | private fun updateUserName(userName: String) { 31 | _viewStates.setState { copy(userName = userName) } 32 | } 33 | 34 | private fun updatePassword(password: String) { 35 | _viewStates.setState { copy(password = password) } 36 | } 37 | 38 | private fun login() { 39 | viewModelScope.launch { 40 | flow { 41 | loginLogic() 42 | emit("登录成功") 43 | }.onStart { 44 | _viewEvents.setEvent(LoginViewEvent.ShowLoadingDialog) 45 | }.onEach { 46 | _viewEvents.setEvent( 47 | LoginViewEvent.DismissLoadingDialog, LoginViewEvent.ShowToast(it) 48 | ) 49 | }.catch { 50 | _viewStates.setState { copy(password = "") } 51 | _viewEvents.setEvent( 52 | LoginViewEvent.DismissLoadingDialog, LoginViewEvent.ShowToast("登录失败") 53 | ) 54 | }.collect() 55 | } 56 | } 57 | 58 | private suspend fun loginLogic() { 59 | withState(viewStates) { 60 | val userName = it.userName 61 | val password = it.password 62 | delay(2000) 63 | throw Exception("登录失败") 64 | "$userName,$password" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/login/livedata/LoginActivity.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.login.livedata 2 | 3 | import android.app.ProgressDialog 4 | import android.os.Bundle 5 | import android.view.View 6 | import androidx.activity.viewModels 7 | import androidx.appcompat.app.AppCompatActivity 8 | import androidx.core.widget.addTextChangedListener 9 | import com.zj.architecture.R 10 | import com.zj.architecture.login.LoginViewAction 11 | import com.zj.architecture.login.LoginViewEvent 12 | import com.zj.architecture.login.LoginViewState 13 | import com.zj.mvi.core.observeEvent 14 | import com.zj.mvi.core.observeState 15 | import com.zj.architecture.utils.toast 16 | import kotlinx.android.synthetic.main.layout_login.* 17 | 18 | class LoginActivity : AppCompatActivity() { 19 | private val viewModel by viewModels() 20 | override fun onCreate(savedInstanceState: Bundle?) { 21 | super.onCreate(savedInstanceState) 22 | setContentView(R.layout.layout_login) 23 | initView() 24 | initViewStates() 25 | initViewEvents() 26 | } 27 | 28 | private fun initView() { 29 | edit_user_name.addTextChangedListener { 30 | viewModel.dispatch(LoginViewAction.UpdateUserName(it.toString())) 31 | } 32 | edit_password.addTextChangedListener { 33 | viewModel.dispatch(LoginViewAction.UpdatePassword(it.toString())) 34 | } 35 | btn_login.setOnClickListener { 36 | viewModel.dispatch(LoginViewAction.Login) 37 | } 38 | } 39 | 40 | private fun initViewStates() { 41 | viewModel.viewStates.let { states -> 42 | states.observeState(this, LoginViewState::userName) { 43 | edit_user_name.setText(it) 44 | edit_user_name.setSelection(it.length) 45 | } 46 | states.observeState(this, LoginViewState::password) { 47 | edit_password.setText(it) 48 | edit_password.setSelection(it.length) 49 | } 50 | states.observeState(this, LoginViewState::isLoginEnable) { 51 | btn_login.isEnabled = it 52 | btn_login.alpha = if (it) 1f else 0.5f 53 | } 54 | states.observeState(this, LoginViewState::passwordTipVisible) { 55 | tv_label.visibility = if (it) View.VISIBLE else View.INVISIBLE 56 | } 57 | } 58 | } 59 | 60 | private fun initViewEvents() { 61 | viewModel.viewEvents.observeEvent(this) { 62 | when (it) { 63 | is LoginViewEvent.ShowToast -> toast(it.message) 64 | is LoginViewEvent.ShowLoadingDialog -> showLoadingDialog() 65 | is LoginViewEvent.DismissLoadingDialog -> dismissLoadingDialog() 66 | } 67 | } 68 | } 69 | 70 | private var progressDialog: ProgressDialog? = null 71 | 72 | private fun showLoadingDialog() { 73 | if (progressDialog == null) 74 | progressDialog = ProgressDialog(this) 75 | progressDialog?.show() 76 | } 77 | 78 | private fun dismissLoadingDialog() { 79 | progressDialog?.takeIf { it.isShowing }?.dismiss() 80 | } 81 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/login/livedata/LoginViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.login.livedata 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import com.zj.architecture.login.LoginViewAction 7 | import com.zj.architecture.login.LoginViewEvent 8 | import com.zj.architecture.login.LoginViewState 9 | import com.zj.architecture.utils.asLiveData 10 | import com.zj.mvi.core.LiveEvents 11 | import com.zj.mvi.core.setEvent 12 | import com.zj.mvi.core.setState 13 | import com.zj.mvi.core.withState 14 | import kotlinx.coroutines.delay 15 | import kotlinx.coroutines.flow.* 16 | import kotlinx.coroutines.launch 17 | 18 | class LoginViewModel : ViewModel() { 19 | private val _viewStates = MutableLiveData(LoginViewState()) 20 | val viewStates = _viewStates.asLiveData() 21 | private val _viewEvents: LiveEvents = LiveEvents() 22 | val viewEvents = _viewEvents.asLiveData() 23 | 24 | fun dispatch(viewAction: LoginViewAction) { 25 | when (viewAction) { 26 | is LoginViewAction.UpdateUserName -> updateUserName(viewAction.userName) 27 | is LoginViewAction.UpdatePassword -> updatePassword(viewAction.password) 28 | is LoginViewAction.Login -> login() 29 | } 30 | } 31 | 32 | private fun updateUserName(userName: String) { 33 | _viewStates.setState { copy(userName = userName) } 34 | } 35 | 36 | private fun updatePassword(password: String) { 37 | _viewStates.setState { copy(password = password) } 38 | } 39 | 40 | private fun login() { 41 | viewModelScope.launch { 42 | flow { 43 | loginLogic() 44 | emit("登录成功") 45 | }.onStart { 46 | _viewEvents.setEvent(LoginViewEvent.ShowLoadingDialog) 47 | }.onEach { 48 | _viewEvents.setEvent( 49 | LoginViewEvent.DismissLoadingDialog, LoginViewEvent.ShowToast(it) 50 | ) 51 | }.catch { 52 | _viewStates.setState { copy(password = "") } 53 | _viewEvents.setEvent( 54 | LoginViewEvent.DismissLoadingDialog, LoginViewEvent.ShowToast("登录失败") 55 | ) 56 | }.collect() 57 | } 58 | } 59 | 60 | private suspend fun loginLogic() { 61 | withState(viewStates) { 62 | val userName = it.userName 63 | val password = it.password 64 | delay(2000) 65 | throw Exception("登录失败") 66 | "$userName,$password" 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/mainscreen/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.mainscreen 2 | 3 | import android.os.Bundle 4 | import androidx.activity.viewModels 5 | import androidx.appcompat.app.AppCompatActivity 6 | import com.google.android.material.snackbar.Snackbar 7 | import com.zj.architecture.R 8 | import com.zj.mvi.core.observeEvent 9 | import com.zj.mvi.core.observeState 10 | import com.zj.architecture.repository.NewsItem 11 | import com.zj.architecture.utils.FetchStatus 12 | import com.zj.architecture.utils.toast 13 | import kotlinx.android.synthetic.main.activity_main.* 14 | 15 | class MainActivity : AppCompatActivity() { 16 | private val viewModel: MainViewModel by viewModels() 17 | 18 | private val newsRvAdapter by lazy { 19 | NewsRvAdapter { 20 | viewModel.dispatch(MainViewAction.NewsItemClicked(it.tag as NewsItem)) 21 | } 22 | } 23 | 24 | override fun onCreate(savedInstanceState: Bundle?) { 25 | super.onCreate(savedInstanceState) 26 | setContentView(R.layout.activity_main) 27 | initView() 28 | initViewModel() 29 | } 30 | 31 | private fun initView() { 32 | rvNewsHome.adapter = newsRvAdapter 33 | 34 | srlNewsHome.setOnRefreshListener { 35 | viewModel.dispatch(MainViewAction.OnSwipeRefresh) 36 | } 37 | 38 | fabStar.setOnClickListener { 39 | viewModel.dispatch(MainViewAction.FabClicked) 40 | } 41 | } 42 | 43 | private fun initViewModel() { 44 | viewModel.viewStates.run { 45 | observeState(this@MainActivity, MainViewState::newsList) { 46 | newsRvAdapter.submitList(it) 47 | } 48 | observeState(this@MainActivity, MainViewState::fetchStatus) { 49 | when (it) { 50 | is FetchStatus.Fetched -> { 51 | srlNewsHome.isRefreshing = false 52 | } 53 | is FetchStatus.NotFetched -> { 54 | viewModel.dispatch(MainViewAction.FetchNews) 55 | srlNewsHome.isRefreshing = false 56 | } 57 | is FetchStatus.Fetching -> { 58 | srlNewsHome.isRefreshing = true 59 | } 60 | } 61 | } 62 | } 63 | viewModel.viewEvents.observeEvent(this) { 64 | renderViewEvent(it) 65 | } 66 | } 67 | 68 | private fun renderViewEvent(viewEvent: MainViewEvent) { 69 | when (viewEvent) { 70 | is MainViewEvent.ShowSnackbar -> { 71 | Snackbar.make(coordinatorLayoutRoot, viewEvent.message, Snackbar.LENGTH_SHORT) 72 | .show() 73 | } 74 | is MainViewEvent.ShowToast -> { 75 | toast(message = viewEvent.message) 76 | } 77 | } 78 | } 79 | } 80 | 81 | -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/mainscreen/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.mainscreen 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import com.zj.architecture.repository.NewsItem 7 | import com.zj.architecture.repository.NewsRepository 8 | import com.zj.mvi.core.setEvent 9 | import com.zj.mvi.core.setState 10 | import com.zj.architecture.utils.FetchStatus 11 | import com.zj.architecture.utils.PageState 12 | import com.zj.architecture.utils.asLiveData 13 | import com.zj.mvi.core.SingleLiveEvents 14 | import kotlinx.coroutines.launch 15 | 16 | 17 | class MainViewModel : ViewModel() { 18 | private var count: Int = 0 19 | private val repository: NewsRepository = NewsRepository.getInstance() 20 | private val _viewStates: MutableLiveData = MutableLiveData(MainViewState()) 21 | val viewStates = _viewStates.asLiveData() 22 | private val _viewEvents: SingleLiveEvents = SingleLiveEvents() //一次性的事件,与页面状态分开管理 23 | val viewEvents = _viewEvents.asLiveData() 24 | 25 | fun dispatch(viewAction: MainViewAction) { 26 | when (viewAction) { 27 | is MainViewAction.NewsItemClicked -> newsItemClicked(viewAction.newsItem) 28 | MainViewAction.FabClicked -> fabClicked() 29 | MainViewAction.OnSwipeRefresh -> fetchNews() 30 | MainViewAction.FetchNews -> fetchNews() 31 | } 32 | } 33 | 34 | private fun newsItemClicked(newsItem: NewsItem) { 35 | _viewEvents.setEvent(MainViewEvent.ShowSnackbar(newsItem.title)) 36 | } 37 | 38 | private fun fabClicked() { 39 | count++ 40 | _viewEvents.setEvent(MainViewEvent.ShowToast(message = "Fab clicked count $count")) 41 | } 42 | 43 | private fun fetchNews() { 44 | _viewStates.setState { 45 | copy(fetchStatus = FetchStatus.Fetching) 46 | } 47 | viewModelScope.launch { 48 | when (val result = repository.getMockApiResponse()) { 49 | is PageState.Error -> { 50 | _viewStates.setState { 51 | copy(fetchStatus = FetchStatus.Fetched) 52 | } 53 | _viewEvents.setEvent(MainViewEvent.ShowToast(message = result.message)) 54 | } 55 | is PageState.Success -> { 56 | _viewStates.setState { 57 | copy(fetchStatus = FetchStatus.Fetched, newsList = result.data) 58 | } 59 | } 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/mainscreen/MainViewStates.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.mainscreen 2 | 3 | import com.zj.architecture.repository.NewsItem 4 | import com.zj.architecture.utils.FetchStatus 5 | 6 | 7 | data class MainViewState( 8 | val fetchStatus: FetchStatus = FetchStatus.NotFetched, 9 | val newsList: List = emptyList() 10 | ) 11 | 12 | sealed class MainViewEvent { 13 | data class ShowSnackbar(val message: String) : MainViewEvent() 14 | data class ShowToast(val message: String) : MainViewEvent() 15 | } 16 | 17 | sealed class MainViewAction { 18 | data class NewsItemClicked(val newsItem: NewsItem) : MainViewAction() 19 | object FabClicked : MainViewAction() 20 | object OnSwipeRefresh : MainViewAction() 21 | object FetchNews : MainViewAction() 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/mainscreen/NewsRvAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.mainscreen 2 | 3 | import android.view.View 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.DiffUtil 6 | import androidx.recyclerview.widget.ListAdapter 7 | import androidx.recyclerview.widget.RecyclerView 8 | import coil.api.load 9 | import com.zj.architecture.R 10 | import com.zj.architecture.repository.NewsItem 11 | import com.zj.architecture.utils.inflate 12 | import kotlinx.android.extensions.LayoutContainer 13 | import kotlinx.android.synthetic.main.item_view.view.* 14 | 15 | class NewsRvAdapter(private val listener: (View) -> Unit) : 16 | ListAdapter(NewsItemItemCallback()) { 17 | 18 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder { 19 | return MyViewHolder(inflate(parent.context, R.layout.item_view, parent), listener) 20 | } 21 | 22 | override fun onBindViewHolder(holder: MyViewHolder, position: Int) { 23 | holder.bind(getItem(position)) 24 | } 25 | 26 | override fun getItemCount() = currentList.size 27 | 28 | inner class MyViewHolder(override val containerView: View, listener: (View) -> Unit) : 29 | RecyclerView.ViewHolder(containerView), 30 | LayoutContainer { 31 | 32 | init { 33 | itemView.setOnClickListener(listener) 34 | } 35 | 36 | fun bind(newsItem: NewsItem) = 37 | with(itemView) { 38 | itemView.tag = newsItem 39 | tvTitle.text = newsItem.title 40 | tvDescription.text = newsItem.description 41 | ivThumbnail.load(newsItem.imageUrl) { 42 | crossfade(true) 43 | placeholder(R.mipmap.ic_launcher) 44 | } 45 | } 46 | } 47 | 48 | internal class NewsItemItemCallback : DiffUtil.ItemCallback() { 49 | override fun areItemsTheSame(oldItem: NewsItem, newItem: NewsItem): Boolean { 50 | return oldItem == newItem 51 | } 52 | 53 | override fun areContentsTheSame(oldItem: NewsItem, newItem: NewsItem): Boolean { 54 | return oldItem.title == newItem.title 55 | } 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/mockapi/MockApi.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.mockapi 2 | 3 | import com.zj.architecture.utils.BASE_URL 4 | import okhttp3.OkHttpClient 5 | import retrofit2.Retrofit 6 | import retrofit2.converter.gson.GsonConverterFactory 7 | import retrofit2.http.GET 8 | 9 | interface MockApi { 10 | @GET("mock") 11 | suspend fun getLatestNews(): MockApiResponse 12 | 13 | companion object { 14 | // Please do not follow this code as this has been 15 | // modified to intercept API calls with mock response. 16 | fun create(): MockApi { 17 | val okHttpClient = OkHttpClient() 18 | .newBuilder() 19 | .addInterceptor(MockInterceptor()) 20 | .build() 21 | 22 | return Retrofit.Builder() 23 | .client(okHttpClient) 24 | .baseUrl(BASE_URL) 25 | .addConverterFactory(GsonConverterFactory.create()) 26 | .build() 27 | .create(MockApi::class.java) 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/mockapi/MockApiResponse.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.mockapi 2 | 3 | import com.zj.architecture.repository.NewsItem 4 | 5 | data class MockApiResponse( 6 | val articles: List? = null 7 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/mockapi/MockInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.mockapi 2 | 3 | import com.zj.architecture.BuildConfig 4 | import okhttp3.* 5 | 6 | class MockInterceptor : Interceptor { 7 | 8 | override fun intercept(chain: Interceptor.Chain): Response { 9 | if (BuildConfig.DEBUG) { 10 | val uri = chain.request().url().uri().toString() 11 | val responseString = when { 12 | uri.endsWith("mock") -> getMockApiResponse 13 | else -> "" 14 | } 15 | 16 | return Response.Builder() 17 | .request(chain.request()) 18 | .code(200) 19 | .protocol(Protocol.HTTP_2) 20 | .message(responseString) 21 | .body( 22 | ResponseBody.create( 23 | MediaType.parse("application/json"), 24 | responseString.toByteArray() 25 | ) 26 | ) 27 | .addHeader("content-type", "application/json") 28 | .build() 29 | } else { 30 | //just to be on safe side. 31 | throw IllegalAccessError("""MockInterceptor is only meant for Testing Purposes and bound to be used only with DEBUG mode""") 32 | } 33 | } 34 | } 35 | 36 | const val getMockApiResponse = """ 37 | { 38 | "articles": [ 39 | { 40 | "title": "Title", 41 | "description": "Description", 42 | "imageUrl": "imageUrl" 43 | }, 44 | { 45 | "title": "Title", 46 | "description": "Description", 47 | "imageUrl": "imageUrl" 48 | } 49 | ] 50 | }""" -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/network/CoroutineScopeHelper.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.network 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Job 5 | import kotlinx.coroutines.launch 6 | 7 | class CoroutineScopeHelper(private val coroutineScope: CoroutineScope) { 8 | fun rxLaunch(init: LaunchBuilder.() -> Unit): Job { 9 | val result = LaunchBuilder().apply(init) 10 | val handler = NetworkExceptionHandler { 11 | result.onError?.invoke(it) 12 | } 13 | return coroutineScope.launch(handler) { 14 | val res: T = result.onRequest() 15 | result.onSuccess?.invoke(res) 16 | } 17 | } 18 | } 19 | 20 | class LaunchBuilder { 21 | lateinit var onRequest: (suspend () -> T) 22 | var onSuccess: ((data: T) -> Unit)? = null 23 | var onError: ((Throwable) -> Unit)? = null 24 | } 25 | 26 | fun CoroutineScope.rxLaunch(init: LaunchBuilder.() -> Unit) { 27 | val scopeHelper = CoroutineScopeHelper(this) 28 | scopeHelper.rxLaunch(init) 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/network/NetworkExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.network 2 | 3 | import com.zj.architecture.MyApp 4 | import com.zj.architecture.utils.toast 5 | import kotlinx.coroutines.CoroutineExceptionHandler 6 | import java.net.SocketTimeoutException 7 | import java.net.UnknownHostException 8 | import kotlin.coroutines.AbstractCoroutineContextElement 9 | import kotlin.coroutines.CoroutineContext 10 | 11 | /** 12 | * 通用异常处理 13 | * 在这里处理一些页面通用的异常逻辑 14 | * 可以根据业务需求自定义 15 | */ 16 | class NetworkExceptionHandler( 17 | private val onException: (e: Throwable) -> Unit = {} 18 | ) : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler { 19 | override fun handleException(context: CoroutineContext, exception: Throwable) { 20 | onException.invoke(exception) 21 | if (exception is UnknownHostException || exception is SocketTimeoutException) { 22 | MyApp.get().toast("发生网络错误,请稍后重试") 23 | } else { 24 | MyApp.get().toast("请求失败,请重试") 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/network/NetworkExt.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.network 2 | 3 | import com.zj.architecture.MyApp 4 | import com.zj.architecture.utils.toast 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.FlowCollector 7 | import kotlinx.coroutines.flow.catch 8 | import java.net.SocketTimeoutException 9 | import java.net.UnknownHostException 10 | 11 | fun Flow.commonCatch(action: suspend FlowCollector.(cause: Throwable) -> Unit): Flow { 12 | return this.catch { 13 | if (it is UnknownHostException || it is SocketTimeoutException) { 14 | MyApp.get().toast("发生网络错误,请稍后重试") 15 | } else { 16 | MyApp.get().toast("请求失败,请重试") 17 | } 18 | action(it) 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/networkscreen/FlowViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.networkscreen 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import com.zj.architecture.network.commonCatch 7 | import com.zj.mvi.core.setEvent 8 | import com.zj.mvi.core.setState 9 | import com.zj.architecture.utils.asLiveData 10 | import kotlinx.coroutines.delay 11 | import kotlinx.coroutines.flow.* 12 | import kotlinx.coroutines.launch 13 | 14 | class FlowViewModel : ViewModel() { 15 | private val _viewStates = MutableLiveData(NetworkViewState()) 16 | val viewStates = _viewStates.asLiveData() 17 | private val _viewEvents: com.zj.mvi.core.SingleLiveEvents = 18 | com.zj.mvi.core.SingleLiveEvents() 19 | val viewEvents = _viewEvents.asLiveData() 20 | 21 | fun dispatch(viewAction: NetworkViewAction) { 22 | when (viewAction) { 23 | is NetworkViewAction.PageRequest -> pageRequest() 24 | is NetworkViewAction.PartRequest -> partRequest() 25 | is NetworkViewAction.MultiRequest -> multiSourceRequest() 26 | is NetworkViewAction.ErrorRequest -> errorRequest() 27 | } 28 | } 29 | 30 | /** 31 | * 页面请求,通常包括刷新页面loading状态等 32 | */ 33 | private fun pageRequest() { 34 | viewModelScope.launch { 35 | flow { 36 | delay(2000) 37 | emit("页面请求成功") 38 | }.onStart { 39 | _viewStates.setState { copy(pageStatus = PageStatus.Loading) } 40 | }.onEach { 41 | _viewStates.setState { copy(content = it, pageStatus = PageStatus.Success) } 42 | }.commonCatch { 43 | _viewStates.setState { copy(pageStatus = PageStatus.Error(it)) } 44 | }.collect() 45 | } 46 | } 47 | 48 | /** 49 | * 页面局部请求,例如点赞收藏等,通常需要弹dialog或toast 50 | */ 51 | private fun partRequest() { 52 | viewModelScope.launch { 53 | flow { 54 | delay(2000) 55 | emit("点赞成功") 56 | }.onStart { 57 | _viewEvents.setEvent(NetworkViewEvent.ShowLoadingDialog) 58 | }.onEach { 59 | _viewEvents.setEvent( 60 | NetworkViewEvent.DismissLoadingDialog, NetworkViewEvent.ShowToast(it) 61 | ) 62 | _viewStates.setState { copy(content = it) } 63 | }.commonCatch { 64 | _viewEvents.setEvent(NetworkViewEvent.DismissLoadingDialog) 65 | }.collect() 66 | } 67 | } 68 | 69 | /** 70 | * 多数据源请求 71 | */ 72 | private fun multiSourceRequest() { 73 | viewModelScope.launch { 74 | val flow1 = flow { 75 | delay(1000) 76 | emit("数据源1") 77 | } 78 | val flow2 = flow { 79 | delay(2000) 80 | emit("数据源2") 81 | } 82 | flow1.zip(flow2) { a, b -> 83 | "$a,$b" 84 | }.onStart { 85 | _viewEvents.setEvent(NetworkViewEvent.ShowLoadingDialog) 86 | }.onEach { 87 | _viewEvents.setEvent( 88 | NetworkViewEvent.DismissLoadingDialog, NetworkViewEvent.ShowToast(it) 89 | ) 90 | _viewStates.setState { copy(content = it) } 91 | }.commonCatch { 92 | _viewEvents.setEvent(NetworkViewEvent.DismissLoadingDialog) 93 | }.collect() 94 | } 95 | } 96 | 97 | /** 98 | * 请求错误示例 99 | */ 100 | private fun errorRequest() { 101 | viewModelScope.launch { 102 | flow { 103 | delay(2000) 104 | throw NullPointerException("") 105 | emit("请求失败") 106 | }.onStart { 107 | _viewEvents.setEvent(NetworkViewEvent.ShowLoadingDialog) 108 | }.onEach { 109 | _viewEvents.setEvent( 110 | NetworkViewEvent.DismissLoadingDialog, NetworkViewEvent.ShowToast(it) 111 | ) 112 | _viewStates.setState { copy(content = it) } 113 | }.commonCatch { 114 | _viewEvents.setEvent(NetworkViewEvent.DismissLoadingDialog) 115 | }.collect() 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/networkscreen/NetworkActivity.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.networkscreen 2 | 3 | import android.app.ProgressDialog 4 | import android.os.Bundle 5 | import android.view.View 6 | import androidx.activity.viewModels 7 | import androidx.appcompat.app.AppCompatActivity 8 | import com.zj.architecture.R 9 | import com.zj.mvi.core.observeEvent 10 | import com.zj.mvi.core.observeState 11 | import com.zj.architecture.utils.toast 12 | import kotlinx.android.synthetic.main.activity_network.* 13 | 14 | class NetworkActivity : AppCompatActivity() { 15 | private val viewModel by viewModels() 16 | override fun onCreate(savedInstanceState: Bundle?) { 17 | super.onCreate(savedInstanceState) 18 | setContentView(R.layout.activity_network) 19 | initViewModel() 20 | } 21 | 22 | private fun initViewModel() { 23 | viewModel.viewStates.let { state -> 24 | state.observeState(this, NetworkViewState::pageStatus) { 25 | when (it) { 26 | is PageStatus.Success -> state_layout.showContent() 27 | is PageStatus.Loading -> state_layout.showLoading() 28 | is PageStatus.Error -> state_layout.showError() 29 | } 30 | } 31 | state.observeState(this, NetworkViewState::content) { 32 | tv_content.text = it 33 | } 34 | } 35 | 36 | viewModel.viewEvents.observeEvent(this) { 37 | when (it) { 38 | is NetworkViewEvent.ShowToast -> toast(it.message) 39 | is NetworkViewEvent.ShowLoadingDialog -> showLoadingDialog() 40 | is NetworkViewEvent.DismissLoadingDialog -> dismissLoadingDialog() 41 | } 42 | } 43 | } 44 | 45 | fun simpleRequest(view: View) { 46 | viewModel.dispatch(NetworkViewAction.PageRequest) 47 | } 48 | 49 | fun partRequest(view: View) { 50 | viewModel.dispatch(NetworkViewAction.PartRequest) 51 | } 52 | 53 | fun multiSource(view: View) { 54 | viewModel.dispatch(NetworkViewAction.MultiRequest) 55 | } 56 | 57 | fun errorRequest(view: View) { 58 | viewModel.dispatch(NetworkViewAction.ErrorRequest) 59 | } 60 | 61 | private var progressDialog: ProgressDialog? = null 62 | 63 | private fun showLoadingDialog() { 64 | if (progressDialog == null) 65 | progressDialog = ProgressDialog(this) 66 | progressDialog?.show() 67 | } 68 | 69 | private fun dismissLoadingDialog() { 70 | progressDialog?.takeIf { it.isShowing }?.dismiss() 71 | } 72 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/networkscreen/NetworkViewState.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.networkscreen 2 | 3 | data class NetworkViewState( 4 | val content: String = "等待网络请求内容", 5 | val pageStatus: PageStatus = PageStatus.Success 6 | ) 7 | 8 | sealed class NetworkViewEvent { 9 | data class ShowToast(val message: String) : NetworkViewEvent() 10 | object ShowLoadingDialog : NetworkViewEvent() 11 | object DismissLoadingDialog : NetworkViewEvent() 12 | } 13 | 14 | sealed class NetworkViewAction { 15 | object PageRequest : NetworkViewAction() 16 | object PartRequest : NetworkViewAction() 17 | object MultiRequest : NetworkViewAction() 18 | object ErrorRequest : NetworkViewAction() 19 | } 20 | 21 | 22 | sealed class PageStatus { 23 | object Loading : PageStatus() 24 | object Success : PageStatus() 25 | data class Error(val throwable: Throwable) : PageStatus() 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/repository/NewsItem.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.repository 2 | 3 | data class NewsItem( 4 | val title: String, 5 | val description: String, 6 | val imageUrl: String 7 | ) 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/repository/NewsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.repository 2 | 3 | import com.zj.architecture.mockapi.MockApi 4 | import com.zj.architecture.utils.PageState 5 | import kotlinx.coroutines.delay 6 | 7 | class NewsRepository { 8 | 9 | companion object { 10 | // For Singleton instantiation 11 | @Volatile 12 | private var instance: NewsRepository? = null 13 | 14 | fun getInstance() = 15 | instance ?: synchronized(this) { 16 | instance ?: NewsRepository().also { instance = it } 17 | } 18 | } 19 | 20 | suspend fun getMockApiResponse(): PageState> { 21 | val articlesApiResult = try { 22 | delay(2000) 23 | MockApi.create().getLatestNews() 24 | } catch (e: Exception) { 25 | return PageState.Error(e) 26 | } 27 | 28 | articlesApiResult.articles?.let { list -> 29 | return PageState.Success(data = list) 30 | } ?: run { 31 | return PageState.Error("Failed to get News") 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/zj/architecture/utils/AppUtils.kt: -------------------------------------------------------------------------------- 1 | package com.zj.architecture.utils 2 | 3 | import android.content.Context 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import android.widget.Toast 8 | import androidx.lifecycle.LifecycleOwner 9 | import androidx.lifecycle.LiveData 10 | import androidx.lifecycle.MutableLiveData 11 | import androidx.lifecycle.distinctUntilChanged 12 | import androidx.lifecycle.map 13 | import kotlin.reflect.KProperty1 14 | 15 | const val BASE_URL = "https://your_api_endpoint.com/" 16 | 17 | fun inflate( 18 | context: Context, 19 | viewId: Int, 20 | parent: ViewGroup? = null, 21 | attachToRoot: Boolean = false 22 | ): View { 23 | return LayoutInflater.from(context).inflate(viewId, parent, attachToRoot) 24 | } 25 | 26 | fun Context.toast(message: String, length: Int = Toast.LENGTH_SHORT) { 27 | Toast.makeText(this, message, length).show() 28 | } 29 | 30 | //LCE -> Loading/Content/Error 31 | sealed class PageState { 32 | data class Success(val data: T) : PageState() 33 | data class Error(val message: String) : PageState() { 34 | constructor(t: Throwable) : this(t.message ?: "") 35 | } 36 | } 37 | 38 | fun MutableLiveData.asLiveData(): LiveData { 39 | return this 40 | } 41 | 42 | sealed class FetchStatus { 43 | object Fetching : FetchStatus() 44 | object Fetched : FetchStatus() 45 | object NotFetched : FetchStatus() 46 | } 47 | 48 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 12 | 13 | 17 | 18 | 25 | 26 | 27 | 28 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_network.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 13 | 19 | 20 |