其他
巧用MVVM搭建GitHub客户端
https://juejin.im/user/593f7b33fe88c2006a37eb9b
Model(数据层):负责处理数据逻辑。 View(视图层):负责处理视图显示,在Android中使用xml描述视图。 Controller(控制层):在Android中的Activity和Fragment承担此层的重任,负责处理业务逻辑。
Model(数据层):负责处理数据逻辑。 View(视图层):负责处理视图显示,在Android中使用xml或者Java/Kotlin代码去实现视图,Activity和Fragment承担了此层的责任。 Presenter:负责连接Model层和View层,是这两层的中间纽带,负责处理业务逻辑。
interface View : BaseView<Presenter> {
var isActive: Boolean
fun showEmptyTaskError()
fun showTasksList()
fun setTitle(title: String)
fun setDescription(description: String)
}
interface Presenter : BasePresenter {
var isDataMissing: Boolean
fun saveTask(title: String, description: String)
fun populateTask()
}
}
尽管减少了View层的代码,但是随着业务的复杂度不断提高,Presenter层的代码也会变得越来越臃肿。 View层和Presenter层是通过接口交互的,随着业务的复杂度不断提高,接口数量会大量增加。 如果View层更新的话,就像UI的输入和数据的变化,都需要主动去调用Presenter层的代码,缺乏自动性和监听性。 MVP是以UI和事件为驱动的传统模型,更新UI需要保证能持有控件的引用,而且更新UI需要考虑Activity或者Fragment的生命周期,防止内存泄漏。
Model(数据层):负责处理数据逻辑。 View(视图层):负责处理视图显示,在Android中使用xml或者Java/Kotlin代码去实现视图,Activity和Fragment承担了此层的责任。 ViewModel:负责连接Model层和View层,是这两层的中间纽带,负责处理业务逻辑,View层和ViewModel层是双向绑定的,View层的变动会自动反映在ViewModel层,ViewModel层的变动也会自动反映在View层。
local:本地数据,存放本地存储逻辑(MMKV相关的逻辑),例如:UserLocalDataSource(用户本地数据源)。 model:数据类,存放请求数据类(request)和响应数据类(response),例如:LoginRequestData(登录请求数据类)、UserAccessTokenData(用户访问Token数据类)、UserInfoData(用户信息数据类)、ListData(基础的列表数据类)和Repository(GitHub仓库请求和响应数据类)。 remote:远程数据,存放网络请求逻辑(OkHttp3和Retrofit2相关的逻辑),例如:UserRemoteDataSource(用户远程数据源)和RepositoryRemoteDataSource(GitHub仓库远程数据源)。 repository:仓库,例如:UserInfoRepository(用户信息仓库)和GitHubRepository(GitHub仓库)。
ApplicationComponent:Application组件,将AndroidSupportInjectionModule、ApplicationModule、NetworkModule、RepositoryModule、MainModule、UserModule和GitHubRepositoryModule注入到Application。 ApplicationModule:提供跟随Application生命周期的业务模块,例如:LocalDataSource(本地数据源)和RemoteDataSource(远程数据源)。 GitHubRepositoryModule:业务模块,提供GitHub仓库业务的模块。 MainModule:业务模块,提供main(启动页和主页)业务的模块。 NetworkModule:网络模块,例如:OkHttp3和Retrofit2。 RepositoryModule:仓库模块,例如:UserInfoRepository(用户信息仓库)和GitHubRepository(GitHub仓库)。 UserModule:业务模块,提供用户业务的模块。 ViewModelFactory:ViewModel工厂,创建不同业务的ViewModel。
ApplicationModule:存放ApplicationModule、NetworkModule、RepositoryModule、MainModule、UserModule和GitHubRepositoryModule,并且生成ApplicationModules的List提供Koin使用。
main:main(启动页和主页)相关的Activity和ViewModel代码。 recyclerview:RecyclerView相关的代码,包括BaseViewHolder、BaseViewType、NoDataViewType、BaseDataBindingAdapter和MultiViewTypeDataBindingAdapter。 repository:GitHub仓库相关的Activity、Fragment、ViewModel和Adapter代码。 user:用户相关的Activity、Fragment和ViewModel代码。 BaseActivity:Acitivity的基类。 BaseFragment:Fragment的基类。 BaseViewModel:ViewModel的基类。 NoViewModel:一个继承BaseViewModel的类,如果该Acitivity或者Fragment不需要用到ViewModel的话可以使用这个类。
ActivityExt:存放Activity的扩展函数。 BindingAdapters:存放使用DataBinding的**@BindingAdapters**注解的代码。 BooleanExt:存放Boolean的扩展函数 DateUtils:存放日期相关的代码。 FragmentExt:存放Fragment的扩展函数。 GsonExt:存放Gson相关的扩展函数。 Language:存放GitHub仓库相关的名字和图片。 OnTabSelectedListenerBuilder:存放OnTabSelectedListener相关的代码,用作使用DSL Preferences:存放MMKV相关的代码 SingleLiveEvent:一个生命周期感知的观察对象,在订阅后只发送新的功能,可以用于导航和SnackBar消息等事件,它可以避免一个常见问题,就是如果观察者处于活跃状态,在配置更改(例如:旋转)的时候是可以发射事件,这个类可以解决这个问题,它只在你显式地调用setValue()方法或者call()方法,它才会调用可观察对象。 ToastExt:存放Toast的扩展函数。
AndroidGenericFrameworkAppGlideModule:定义在应用程序(Application)内初始化Glide时要使用的一组依赖项和选项,要注意的是,在一个应用程序(Application)中只能存在一个AppGlideModule,如果是库(Libraries)就必须使用LibraryGlideModule。 AndroidGenericFrameworkApplication:本框架的Application。 AndroidGenericFrameworkConfiguration:存放本框架的配置信息。 AndroidGenericFrameworkExtra:存放Activity和Fragment的附加数据的名称。 AndroidGenericFrameworkFragmentTag:存放Fragment的标记名,这个标记名是为了以后使用FragmentManager的findFragmentByTag(String)方法的时候检索Fragment。
data:FakeDataSource用来创建假的数据源,UserRemoteDataSourceTest(用户远程数据源测试类)和RepositoryRemoteDataSourceTest(GitHub仓库远程数据源测试类)都是模拟API调用。 utils:存放工具文件的测试类。 viewmodel:存放ViewModel的测试类。
/**
* Created by TanJiaJun on 2020/4/4.
*/
@Suppress("unused")
@Module
open class NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(localDataSource: UserLocalDataSource): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(AndroidGenericFrameworkConfiguration.CONNECT_TIMEOUT, TimeUnit.MILLISECONDS)
.readTimeout(AndroidGenericFrameworkConfiguration.READ_TIMEOUT, TimeUnit.MILLISECONDS)
.addInterceptor(BasicAuthInterceptor(localDataSource))
.build()
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit =
Retrofit.Builder()
.client(client)
.addConverterFactory(ScalarsConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create())
.baseUrl(String.format("%1\$s://%2\$s/", "https", AndroidGenericFrameworkConfiguration.HOST))
.build()
}
可以直接作用于挂起函数(suspend fun)。 可以直接返回我们想要的数据对象,而不再返回**Deferred**对象。 不再需要调用协程中await函数,因为Retrofit已经帮我们调用了。
interface Service {
@GET("search/repositories")
suspend fun fetchRepositories(@Query("q") query: String,
@Query("sort") sort: String = "stars"): ListData<RepositoryResponseData>
}
@BindingAdapter(value = ["url", "placeholder", "error"], requireAll = false)
fun ImageView.loadImage(url: String?, placeholder: Drawable?, error: Drawable?) =
Glide
.with(context)
.load(url)
.placeholder(placeholder ?: context.getDrawable(R.mipmap.ic_launcher))
.error(error ?: context.getDrawable(R.mipmap.ic_launcher))
.transition(DrawableTransitionOptions.withCrossFade(DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build()))
.into(this)
可以降低布局和逻辑的耦合度,使代码逻辑更加清晰。 可以省去findViewById这样的代码,大量减少View层的代码。 数据能单向和双向绑定到layout文件。 能够自动进行空判断,可以避免空指针异常。
<ImageView
android:id="@+id/iv_head_portrait"
error="@{@drawable/ic_default_avatar}"
placeholder="@{@drawable/ic_default_avatar}"
url="@{viewModel.avatarUrl}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp"
android:contentDescription="@string/head_portrait"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/divider_line"
tools:background="@drawable/ic_default_avatar" />
override fun onViewCreated(view: View, savedInstanceState: Bundle?) =
with(binding) {
lifecycleOwner = this@LoginFragment
viewModel = this@LoginFragment.viewModel
handlers = this@LoginFragment
}.also {
registerLoadingProgressBarEvent()
registerSnackbarEvent()
observe()
}
@MainThread
public void setLifecycleOwner(@Nullable LifecycleOwner lifecycleOwner) {
if (mLifecycleOwner == lifecycleOwner) {
return;
}
if (mLifecycleOwner != null) {
mLifecycleOwner.getLifecycle().removeObserver(mOnStartListener);
}
mLifecycleOwner = lifecycleOwner;
if (lifecycleOwner != null) {
if (mOnStartListener == null) {
mOnStartListener = new OnStartListener(this);
}
lifecycleOwner.getLifecycle().addObserver(mOnStartListener);
}
for (WeakListener<?> weakListener : mLocalFieldObservers) {
if (weakListener != null) {
weakListener.setLifecycleOwner(lifecycleOwner);
}
}
}
val username = MutableLiveData<String>()
val password = MutableLiveData<String>()
private val _isLoginEnable = MutableLiveData<Boolean>()
val isLoginEnable: LiveData<Boolean> = _isLoginEnable
val isLoginSuccess = MutableLiveData<Boolean>()
fun checkLoginEnable() {
_isLoginEnable.value = !username.value.isNullOrEmpty() && !password.value.isNullOrEmpty()
}
/**
* Created by TanJiaJun on 2020-02-07.
*/
class RepositoryViewModel @Inject constructor(
private val repository: GitHubRepository
) : BaseViewModel() {
private val _isShowRepositoryView = MutableLiveData<Boolean>()
val isShowRepositoryView: LiveData<Boolean> = _isShowRepositoryView
private val _repositories = MutableLiveData<List<RepositoryData>>()
val repositories: LiveData<List<RepositoryData>> = _repositories
fun getRepositories(languageName: String) =
launch(
uiState = UIState(isShowLoadingView = true, isShowErrorView = true),
block = { repository.getRepositories(languageName) },
success = {
if (it.isNotEmpty()) {
_repositories.value = it
_isShowRepositoryView.value = true
}
}
)
}
管理长时间运行的任务,如果管理不当,这些任务可能会阻塞主线程并导致你的应用界面冻结。 提供主线程安全性,或者从主线程安全地调用网络或者磁盘操作。
suspend用于暂停执行当前协程,并保存所有的局部变量。 resume用于让已暂停的协程从其暂停处继续执行。
Dispatchers.Main:使用此调度程序可在Android主线程上运行协程,只能用于界面交互和执行快速工作,例如:调用suspend函数、运行Android界面框架操作和更新LiveData对象。 Dispatchers.IO:此调度程序适合在主线程之外执行磁盘或者网络I/O,例如:操作数据库(使用Room)、从文件中读取数据或者向文件中写入数据和运行任何网络操作。 Dispatcher.Default:此调度程序适合在主线程之外执行占用大量CPU资源的工作,例如:对列表排序和解析JSON。
launch:可以启动新协程,但是不将结果返回给调用方。 async:可以启动新协程,并且允许使用await暂停函数返回结果。
@ExperimentalCoroutinesApi
@FlowPreview
fun login() =
launchUI {
launchFlow {
repository.run {
cacheUsername(username.value ?: "")
cachePassword(password.value ?: "")
authorizations()
}
}
.flatMapMerge {
launchFlow { repository.getUserInfo() }
}
.flowOn(Dispatchers.IO)
.onStart { uiLiveEvent.showLoadingProgressBarEvent.call() }
.catch {
val responseThrowable = ExceptionHandler.handleException(it)
uiLiveEvent.showSnackbarEvent.value = "${responseThrowable.errorCode}:${responseThrowable.errorMessage}"
}
.onCompletion { uiLiveEvent.dismissLoadingProgressBarEvent.call() }
.collect {
repository.run {
cacheUserId(it.id)
cacheName(it.login)
cacheAvatarUrl(it.avatarUrl)
}
isLoginSuccess.value = true
}
}
更好的性能:Google声称提高了13%的处理性能,没有使用反射生成有向无环图,而是在编译阶段生成。 更高效和优雅,而且更容易调试:作为升级版的Dagger,从半静态变成完全静态,从Map式API变成申明式API(例如:@Module),生成的代码更加高效和优雅,一旦出错在编译阶段就能发现。
/**
* Created by TanJiaJun on 2020/3/4.
*/
@Singleton
@Component(
modules = [
AndroidSupportInjectionModule::class,
ApplicationModule::class,
NetworkModule::class,
RepositoryModule::class,
MainModule::class,
UserModule::class,
GitHubRepositoryModule::class
]
)
interface ApplicationComponent : AndroidInjector<AndroidGenericFrameworkApplication> {
@Component.Factory
interface Factory {
fun create(@BindsInstance applicationContext: Context): ApplicationComponent
}
}
/**
* Created by TanJiaJun on 2020/5/5.
*/
val applicationModule = module {
single {
UserLocalDataSource(MMKV.mmkvWithID(
AndroidGenericFrameworkConfiguration.MMKV_ID,
MMKV.SINGLE_PROCESS_MODE,
AndroidGenericFrameworkConfiguration.MMKV_CRYPT_KEY
))
}
single { UserRemoteDataSource(get()) }
single { RepositoryRemoteDataSource(get()) }
}
val networkModule = module {
single<OkHttpClient> {
OkHttpClient.Builder()
.connectTimeout(AndroidGenericFrameworkConfiguration.CONNECT_TIMEOUT, TimeUnit.MILLISECONDS)
.readTimeout(AndroidGenericFrameworkConfiguration.READ_TIMEOUT, TimeUnit.MILLISECONDS)
.addInterceptor(BasicAuthInterceptor(get()))
.build()
}
single<Retrofit> {
Retrofit.Builder()
.client(get())
.addConverterFactory(ScalarsConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create())
.baseUrl(String.format("%1\$s://%2\$s/", SCHEMA_HTTPS, AndroidGenericFrameworkConfiguration.HOST))
.build()
}
}
val repositoryModule = module {
single { UserInfoRepository(get(), get()) }
single { GitHubRepository(get()) }
}
val mainModule = module {
scope<SplashActivity> {
viewModel { SplashViewModel(get()) }
}
scope<MainActivity> {
viewModel { MainViewModel(get()) }
}
}
val userModule = module {
scope<LoginFragment> {
viewModel { LoginViewModel(get()) }
}
scope<PersonalCenterActivity> {
viewModel { PersonalCenterViewModel(get()) }
}
}
val githubRepositoryModule = module {
scope<RepositoryFragment> {
viewModel { RepositoryViewModel(get()) }
}
}
val applicationModules = listOf(
applicationModule,
networkModule,
repositoryModule,
mainModule,
userModule,
githubRepositoryModule
)
private const val SCHEMA_HTTPS = "https"
/**
* Created by TanJiaJun on 2020-01-11.
*/
private inline fun <T> MMKV.delegate(
key: String? = null,
defaultValue: T,
crossinline getter: MMKV.(String, T) -> T,
crossinline setter: MMKV.(String, T) -> Boolean
): ReadWriteProperty<Any, T> =
object : ReadWriteProperty<Any, T> {
override fun getValue(thisRef: Any, property: KProperty<*>): T =
getter(key ?: property.name, defaultValue)
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
setter(key ?: property.name, value)
}
}
fun MMKV.boolean(
key: String? = null,
defaultValue: Boolean = false
): ReadWriteProperty<Any, Boolean> =
delegate(key, defaultValue, MMKV::decodeBool, MMKV::encode)
fun MMKV.int(key: String? = null, defaultValue: Int = 0): ReadWriteProperty<Any, Int> =
delegate(key, defaultValue, MMKV::decodeInt, MMKV::encode)
fun MMKV.long(key: String? = null, defaultValue: Long = 0L): ReadWriteProperty<Any, Long> =
delegate(key, defaultValue, MMKV::decodeLong, MMKV::encode)
fun MMKV.float(key: String? = null, defaultValue: Float = 0.0F): ReadWriteProperty<Any, Float> =
delegate(key, defaultValue, MMKV::decodeFloat, MMKV::encode)
fun MMKV.double(key: String? = null, defaultValue: Double = 0.0): ReadWriteProperty<Any, Double> =
delegate(key, defaultValue, MMKV::decodeDouble, MMKV::encode)
private inline fun <T> MMKV.nullableDefaultValueDelegate(
key: String? = null,
defaultValue: T?,
crossinline getter: MMKV.(String, T?) -> T,
crossinline setter: MMKV.(String, T) -> Boolean
): ReadWriteProperty<Any, T> =
object : ReadWriteProperty<Any, T> {
override fun getValue(thisRef: Any, property: KProperty<*>): T =
getter(key ?: property.name, defaultValue)
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
setter(key ?: property.name, value)
}
}
fun MMKV.byteArray(
key: String? = null,
defaultValue: ByteArray? = null
): ReadWriteProperty<Any, ByteArray> =
nullableDefaultValueDelegate(key, defaultValue, MMKV::decodeBytes, MMKV::encode)
fun MMKV.string(key: String? = null, defaultValue: String? = null): ReadWriteProperty<Any, String> =
nullableDefaultValueDelegate(key, defaultValue, MMKV::decodeString, MMKV::encode)
fun MMKV.stringSet(
key: String? = null,
defaultValue: Set<String>? = null
): ReadWriteProperty<Any, Set<String>> =
nullableDefaultValueDelegate(key, defaultValue, MMKV::decodeStringSet, MMKV::encode)
inline fun <reified T : Parcelable> MMKV.parcelable(
key: String? = null,
defaultValue: T? = null
): ReadWriteProperty<Any, T> =
object : ReadWriteProperty<Any, T> {
override fun getValue(thisRef: Any, property: KProperty<*>): T =
decodeParcelable(key ?: property.name, T::class.java, defaultValue)
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
encode(key ?: property.name, value)
}
}
var accessToken by mmkv.string("user_access_token", "")
var userId by mmkv.int("user_id", -1)
var username by mmkv.string("username", "")
var password by mmkv.string("password", "")
var name by mmkv.string("name", "")
var avatarUrl by mmkv.string("avatar_url", "")
支持垂直方向分页:ViewPager2除了支持水平方向分页,也支持垂直方向分页,可以通过android:orientation属性或者setOrientation()方法来启动垂直分页,代码如下:
支持从右到做(RTL):ViewPager2会根据语言环境自动启动从右到做(RTL)分页,可以通过设置android:layoutDirection属性或者setLayoutDirection()方法来启动RTL分页,代码如下:
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/vp_repository"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tl_repository" />
不能测试静态方法:可以使用PowerMock解决。 Mockito cannot mock/spy because:-final class:这是因为在Kotlin中任何类预设都是final的,Mockito预设情况下不能mock一个final的类。 java.lang.illegalStateException:anyObjecet() must not be null:如果我们使用eq()、any()、capture()和argumentCaptor()的话就会遇到这个问题了,因为这些方法返回的对象可能是null,如果作用在一个非空的参数的话,就会报这个异常了,解决办法是可以使用如下文件: when要加上反引号才能使用:因为when是Kotlin中的关键字。
testImplementation "junit:junit:$junitVersion"
testImplementation "com.squareup.okhttp3:mockwebserver:$okhttpVersion"
testImplementation "io.mockk:mockk:$mockkVersion"
testImplementation "com.google.truth:truth:$truthVersion"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinxCoroutinesVersion"
testImplementation "android.arch.core:core-testing:$coreTestingVersion"
com.squareup.okhttp3:mockwebserver:用来模拟Web服务器的。 com.google.truth:truth:可以使测试断言和失败消息更具有可读性,与AssertJ相似,它支持很多JDK和Guava类型,并且可以扩展到其他类型。
@ExperimentalCoroutinesApi
@FlowPreview
@Test
fun login_success() {
runBlocking {
viewModel.username.value = "1120571286@qq.com"
viewModel.password.value = "password"
coEvery { repository.authorizations() } returns userAccessTokenData
coEvery { repository.getUserInfo() } returns userInfoData
viewModel.login()
val observer = mockk<Observer<Boolean>>(relaxed = true)
viewModel.isLoginSuccess.observeForever(observer)
viewModel.viewModelScope.coroutineContext[Job]?.children?.forEach { it.join() }
verify { observer.onChanged(match { it }) }
}
}
@ExperimentalCoroutinesApi
@FlowPreview
@Test
fun login_failure() {
runBlocking {
viewModel.username.value = "1120571286@qq.com"
viewModel.password.value = "password"
coEvery { repository.authorizations() } returns userAccessTokenData
coEvery { repository.getUserInfo() } throws Throwable("UnknownError")
viewModel.login()
val observer = mockk<Observer<String>>(relaxed = true)
viewModel.uiLiveEvent.showSnackbarEvent.observeForever(observer)
viewModel.viewModelScope.coroutineContext[Job]?.children?.forEach { it.join() }
verify { observer.onChanged(match { it == "0:UnknownError" }) }
}
}
https://github.com/TanJiaJunBeyond
https://github.com/TanJiaJunBeyond/AndroidGenericFramework