在我们写一些项目的时候,通常会对一些常用的一些常用功能进行抽象封装,简单例子:比如BaseActivity,BaseFragment等等…一般这些Base会去承载一些比如标题栏,主题之类的工作,方便Activity的一些风格的统一,也是预留一些函数方便进行HOOK进而实现一些功能。除此之外,一个网络请求也会根据项目采用的技术进行一些封装,比如OkHttp的全局的单例呀,网络请求的成功与失败的回调呀,把相应的状态进行上抛给View,这些都是我们在新建一个项目,采用不同技术方案时需要考虑的问题。
下面我就分享一下比较常用的一些技术方案去实现的一个MVVM的一个基础架构组件。
在Android项目中,因为传统的View布局的方式采用的是通过xml进行控件的布局,那么不可避免的我们需要在代码中进行view的操作,那么我们就需要findViewById,这个方法可能是每一个Android的开发人员都非常熟悉的一个方法,它的作用那我们就不必说,就是发现一个view并获取对应的实例,那么当我们布局文件越来越复杂的时候,我们需要每一个view都find一遍的话,那么明显重复代码冗长且易出错,但是不写又不行。当时你可以说用compose呀,抛掉XML,可是技术的普及总是需要一定的时间,在这之前传统的xml也是不能抛弃的。
在Android中用来代替的findViewById的方法有很多,但是大多已经过时了,或者说已经不推荐了,方案主要有以下几种。
方案 | 状态 | 优缺点 |
---|---|---|
Butter Knife | 停止更新,库作者已不推荐 | - |
kotlin-android-extensions | 谷歌已不推荐 | - |
Data Binding | 可用 | 优点:可以直接实现双向绑定,在XML中支持表达式 缺点:1.BUG比较难定位 2.根标签必须是layout 3.对构建速度和性能有部分影响 |
View Binding | 可用 | 优点:避免findViewById大量重复代码同时精剪了部分功能,避免了Data Binding存在的问题 缺点:不支持双向绑定 |
一般情况下,推荐采用View Binding,View Binding一般使用方法如下:
val binding = ActivityLoginBinding.inflate(layoutInflater)
// val binding = ActivityLoginBinding.inflate(layoutInflater, parent, false)
// val binding = ActivityLoginBinding.bind(view)
binding.tv.text = "Hello Android!"
可能你会觉得如果每个使用这个布局的地方都要inflate一遍,那么我们可以借助kotlin的委托和反射,进一步简化代码。
最终呈现的效果,如下面代码所示:
class TestActivity : AppCompatActivity() {
private val binding: ActivityLoginBinding by binding()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.edName.setText("hello")
}
}
上述代码通过就通过委托和反射的方式,减少inflate和setContentView的代码。
具体实现可以参考:
https://github.com/DylanCaiCoding/ViewBindingKTX
在这部分,我觉得每个项目在Base中封装是不一样的,所以在本文章中,Base这一块反而是比较简单的一块,简单的有个继承关系即可
open class BaseActivity : AppCompatActivity()
open class BaseApplication : Application()
open class BaseViewModel : ViewModel()
有一点需要说明的是,在Base Activity中我们需要尽量不去修改activity的生命周期,或者说添加而外的生命周期,如果是在多人合作的项目中,开发人员错误地理解部分代码,那么就有可能出现问题。所以我们在Base中尽量不要修改对应的生命周期,减少学习成本。
依赖注入或许在Java开发中很常见,其实也是可以应用在Android中用来减少一些重复代码,也可以方便开发人员对代码进行一些简单的测试,只需要替换对应的实现类即可。
那么Android常见的依赖注入方案有
方案 | 优缺点 |
---|---|
Dagger | 优点:功能强大 缺点:学习成本高,在Android上应用需要一定的熟练程度 |
Hilt | 谷歌根据Android平台的特点,在 Dagger 的基础上构建而成,减少了一定的学习成本 |
koin | 根据委托实现,学习成本低 |
这里比较推荐Hilt,那么Hilt的用法比较简单,主要分为两部分,一部分为使用注解表明什么地方需要注入以及注入的对象的作用域。另外一部分就是被注入的对象如何产生。
Hilt接入:
首先,将 hilt-android-gradle-plugin 插件添加到项目的根级 build.gradle 文件中
plugins {
...
id 'com.google.dagger.hilt.android' version '2.44' apply false
}
然后,应用 Gradle 插件并在 app/build.gradle 文件中添加以下依赖项:
...
plugins {
id 'kotlin-kapt'
id 'com.google.dagger.hilt.android'
}
android {
...
}
dependencies {
implementation "com.google.dagger:hilt-android:2.44"
kapt "com.google.dagger:hilt-compiler:2.44"
}
// Allow references to generated code
kapt {
correctErrorTypes true
}
所有使用 Hilt 的应用都必须包含一个带有 @HiltAndroidApp 注解的 Application 类。
@HiltAndroidApp 会触发 Hilt 的代码生成操作,生成的代码包括应用的一个基类,该基类充当应用级依赖项容器。
@HiltAndroidApp
class ExampleApplication : Application() { ... }
Hilt使用如下:
//被注入的对象如何产生
//标记这是一个module.可以通过module模块向 Hilt 提供绑定信息。
@Module
//标记绑定作用域限定到ViewModel
@InstallIn(ViewModelComponent::class)
object RepositoryModule {
//标记方法,提供依赖返回值,即产生对象
@Provides
fun provideTasksRepository(): UserRepository {
return DefaultUserRepository()
}
}
//表明什么地方需要注入
//表明这是一个被注入ViewModel
@HiltViewModel
//注入到构造函数的repository
class TestViewModel @Inject constructor(
private val repository: UserRepository
) : BaseViewModel()
更多Hilt的使用方法参考链接:
https://developer.android.google.cn/training/dependency-injection/hilt-android?hl=zh-cn
一般而言,如果没有特殊情况的话,Android网络请求一般采用的都是OkHttp来实现Http请求,数据格式一般采用Json。当然也有些项目为了性能采用RPC和Protobuf来进行数据通讯。
这里我们采用OkHttp ,Retrofit , Flow 来构建我们的数据传输,模拟的接口就采用玩Android的开放API来举例,接口文档地址:
https://www.wanandroid.com/blog/show/2
第一步导入依赖:
dependencies {
...
//OkHttp
implementation(platform("com.squareup.okhttp3:okhttp-bom:4.10.0"))
implementation("com.squareup.okhttp3:okhttp")
implementation("com.squareup.okhttp3:logging-interceptor")
//retrofit
implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation "com.squareup.retrofit2:converter-gson:2.9.0"
}
第二步构建OkHttp和Retrofit实例,结合Hilt实现
object NetUrlConst {
const val BASE_URL = "https://www.wanandroid.com"
/**
* 登录
*/
const val LOGIN = "/user/login"
}
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
private const val TAG = "NetworkModule"
@Provides
@Singleton
fun providesOKHttpClient():OkHttpClient{
Log.i(TAG,"providesOKHttpClient")
val okHttpClient = OkHttpClient()
.newBuilder()
.addInterceptor(HttpLoggingInterceptor { message ->
Log.i(TAG, message)
}.setLevel(HttpLoggingInterceptor.Level.BODY))
.build()
return okHttpClient
}
@Provides
@Singleton
fun providesRetrofit(client: OkHttpClient):Retrofit{
Log.i(TAG,"providesRetrofit")
return Retrofit.Builder()
.baseUrl(NetUrlConst.BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
}
上面代码就实现了OkHttp和Retrofit的单例,使用的话在需要的地方使用Hilt注入即可。
第三步定义错误码以及返回结果
/**错误码枚举*/
enum class NetCode(val value: Int){
ERROR(-1),
NORMAL(0)
}
/**返回实体基类*/
data class BaseEntity<T>(
val data: T,
val errorCode: Int,
val errorMsg: String
) {
val isSuccess
get() = errorCode == NetCode.NORMAL.value
}
/**用户实体类*/
data class UserEntity(
val id: Int,
val nickname: String,
val password: String,
val publicName: String,
val username: String
)
第四步生成Service
interface UserService {
@POST(NetUrlConst.LOGIN)
suspend fun login(
@Query("username") username: String,
@Query("password") password: String): BaseEntity<UserEntity>
}
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
...
@Provides
fun providesNetUserService(retrofit: Retrofit):UserService{
Log.i(TAG,"providesNetUserService")
return retrofit.create(UserService::class.java)
}
}
接下来我们需要定义,提供数据的Model层的实现形式。
一般而言在Model层会提供一个抽象的Repository来进行数据的提供,来源包括本地以及网络数据。在Repository中分别有不同的DataSource来提供数据,而Repository则屏蔽这些具体的细节统一封装数据返回给ViewModel。
如下图所示
同时由于我们数据流采用的是Flow,那么我们需要定义一个协程的返回基类,用于包含我们成功的信息以及错误的时候的异常信息。
代码如下:
/**
* Author: huangtao
* Date: 2023/1/19
* Desc: 用于协程内容的封装类
* Error用来处理程序异常,不处理业务错误
*/
sealed class Result<out R> {
data class Success<out T>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
override fun toString(): String {
return when (this) {
is Success<*> -> "Success[data=$data]"
is Error -> "Error[exception=$exception]"
}
}
}
val Result<*>.succeeded
get() = this is Result.Success && data != null
有了Result之后我们就可以方便地定义Repository的接口了
我们这边只接入用户登录的功能,那么方法也就一个。
/**
* Desc: 登录的数据接口
*/
interface UserRepository {
/**
* 登录
*/
suspend fun signIn(username: String, password: String): Result<BaseEntity<UserEntity>>
}
/**
* Desc: 登录的数据源接口
*/
interface UserDataSource {
/**
* 登录
*/
suspend fun signIn(username: String, password: String): Result<BaseEntity<UserEntity>>
}
//由于登录功能只有远程的实现,所以这边本地实现略
/**
* Desc: 网络数据源
*/
class RemoteDataSource(
private val service: UserService,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : UserDataSource {
override suspend fun signIn(username: String, password: String) = withContext(ioDispatcher) {
try {
return@withContext Result.Success(service.login(username, password))
} catch (e: Exception) {
return@withContext Result.Error(e)
}
}
}
/**
* Desc: 默认LoginRepository实现
*/
class DefaultUserRepository(
private val remoteDataSource: UserDataSource,
private val localDataSource: UserDataSource = null,
) : UserRepository {
override suspend fun signIn(username: String, password: String): Result<BaseEntity<UserEntity>> {
val result = remoteDataSource.signIn(username, password)
if (result is Result.Success && result.data.isSuccess) {
//TODO 一般而言登录成功后,会进行一些数据缓存的逻辑等
}
return result
}
}
相关实现写好后,我们通过Hilt的依赖注入暴露出去
/**
* Desc: 用户模块的依赖注入
*/
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class Remote
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class Local
@Module
@InstallIn(ViewModelComponent::class)
object RepositoryModule {
@Provides
fun provideTasksRepository(
@Remote remoteDataSource: UserDataSource
): UserRepository {
return DefaultUserRepository(remoteDataSource)
}
}
@Module
@InstallIn(ViewModelComponent::class)
object DataSourceModule {
@Remote
@Provides
fun provideUserRemoteDataSource(
userService: UserService
): UserDataSource {
return RemoteDataSource(userService)
}
// 本地数据源,如果有的话
// @Local
// @Provides
// fun provideTasksLocalDataSource(): UserDataSource {
// return LocalDataSource()
// }
}
那么至此,Model层就算实现完成了。
通过上面的一系列工作,那么我们现在就可以愉快地写界面以及业务逻辑。
我们还是通过简单的用户登录这个界面来举例。
首先是我们的布局界面,简简单单一个按钮,两个输入框。
activity_login.xml
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".LoginActivity">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.33" />
<EditText
android:id="@+id/ed_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="68dp"
android:layout_marginEnd="68dp"
android:background="@null"
android:hint="输入用户名"
android:padding="6dp"
android:textSize="18sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gl" />
<EditText
android:id="@+id/ed_password"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="68dp"
android:layout_marginTop="18dp"
android:layout_marginEnd="68dp"
android:background="@null"
android:hint="输入密码"
android:inputType="textPassword"
android:padding="6dp"
android:textSize="18sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ed_name" />
<Button
android:id="@+id/bt_login"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="68dp"
android:layout_marginTop="18dp"
android:layout_marginEnd="68dp"
android:padding="6dp"
android:text="登录"
android:textSize="18sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ed_password" />
androidx.constraintlayout.widget.ConstraintLayout>
View层activity
@AndroidEntryPoint
class LoginActivity : BaseActivity() {
private val binding: ActivityLoginBinding by binding()
private val mViewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launchWhenResumed {
mViewModel.loginFlow.collect {
if (it == null) return@collect
Toast.makeText(this@LoginActivity, "返回的数据=$it", Toast.LENGTH_LONG).show()
}
}
binding.btLogin.click {
loginLogic()
}
}
private fun loginLogic() {
val name = binding.edName.text.toString().trim()
val password = binding.edPassword.text.toString().trim()
if (!CheckUtils.checkName(name)) {
Toast.makeText(this, "用户名${Constant.NAME_LENGTH}位", Toast.LENGTH_LONG).show()
return
}
if (!CheckUtils.checkPassWord(password)) {
Toast.makeText(this, "密码${Constant.PASSWORD_LENGTH}位", Toast.LENGTH_LONG).show()
return
}
mViewModel.login(name, password)
}
}
ViewModel
@HiltViewModel
class LoginViewModel @Inject constructor(
private val repository: UserRepository
) : BaseViewModel() {
private val mLoginFlow = MutableStateFlow<Result<BaseEntity<UserEntity>>?>(null)
val loginFlow: Flow<Result<BaseEntity<UserEntity>>?> get() = mLoginFlow
fun login(name: String, password: String) {
viewModelScope.launch {
mLoginFlow.value = repository.signIn(name, password)
}
}
}
到此一个简单的MVVM架构就实现了
源码传送门:
https://github.com/huangtaoOO/TaoComponent