1.前言
老生常谈的架构实践,借鉴了很多前辈的东西,应该是没什么新东西吧,算是个人实践瞎搞 😂。
有兴趣就看看,文章共分理论与实践两部分,理论部分根据下面的层次架构图,分析每一层每一个组件的作用。
实践部分使用WanAndroidApi 搞了个组件化项目,分析组件化项目的结构并叙述每个组件存在的理由。
结合理论写了三个小功能,登录,注册,首页列表,每个功能的写法稍微有点点不同,讨论页面复杂时如何拆分代码。
项目地址 android-architecture(gitee.com)
2.理论
2.1应用架构层次图
参考Google最新的架构层次图,做了一点小小的改动。下文将会每层每个组件逐个分析
2.2 展示层
2.2.1 Activity
做应用架构 核心思想就是“拆”,
在没有任何架构之前,完成一个功能所有的代码都在Activity中,随着业务越来越复杂,代码也越来越多,催生出做应用架构的需求,对Activity中大量庞杂的代码进行差分。
拆代码有一些可以拆,有些不可以拆。Activity很难复用,做Android又离不开Activity,页面加载的入口,contentApi的使用等等由于平台特性Activity必须存在。
以往Activity的任务很重,数据加载,逻辑处理,数据绑定,UI加载等等工作他都要负责。
给Activity减负,首先对Activity的定位要改变一下,不把它当作页面看,当作一个场景,功能组装者,把上述功能交给不同的组件完成。
页面是View ,ViewGroup 它们才是真正展示数据的角色。
Activity也不持有数据,数据管理交给ViewModel。
2.2.2 View(可选)
以往的习惯,当前页面内所有的View初始化,数据绑定,设置监听等工作都在Activity内完成,比如:十多个组件,在onCreate() 中 findViewById。 在网络回调中设置数据。
可以使用自定义view进行优化,自定义ViewGroup,只是用来业务处理,将Activity中view的初始化,数据绑定转移到View中。对外开发必要的方法用来绑定数据 和 通信。
Activity持有ViewModel 和 自定义view的引用,负责连接两个组件通信,Activity只剩一些通信接口。
2.2.3 ViewModel
关于ViewModel需要强调的一点是,它并不是MVVM架构中的VM,虽然VM很像ViewModel的缩写。可能这仅仅是我的一家之言,但是根据Google的官方文档,如果我没有看错的话,没有任何一处指明ViewModel是用来完成MVVM结构。
根据文档,它的作用应该是:
- 不跟随Activity因配置变化而重建,替代Activity持有View使用的数据,当Activity配置变化重建时数据依然存在于内存中
- 多个fragment间,在Activity范围内共享数据
- 配合liveData使用
- 配合kotlin 协程使用
- 配合SavedStateHandle 实现Activity重建状态保存
文档提出上述几点ViewModel的使用,没有明确指定它是用来写MVVM的。
ViewModel应该将它理解Activity的数据管理者,ViewModel替Activity持有数据相关的对象,提供LiveData 或 kotlin Flow 使Activity订阅数据。
同时可以进行少量,不复杂的数据逻辑操作。如果ViewModel中代码量过多 或 存在可复用逻辑 应该将逻辑取出放到单独的类中实现。
在层次中ViewModel属于展示层,可以把它当作Activity的影子 或 分身,Activity负责UI,ViewModel负责UI需要的数据。所以ViewModel应该也该和Activity一样的待遇,尽量保持干净简单,不适合做复杂的数据逻辑。
2.3 数据层
2.3.1 DataSource
移动端可能出现的数据源大概有:网络,本地数据库,kv缓存,文件,内存。其中网络是最常见的情况,但是其他数据源也有可能出现。
dataSource 不包含任何业务相关的东西,单纯提供能力,提供访问网络,数据库的能力,在三方组件的基础上进行一定程度的抽象和封装。
目的在于屏蔽实现细节 和 刚简单的使用, 比如:kv缓存,可选的技术有,sharedpreferences,腾讯的MMKV,jetpack中的dataStore。
如果直接调用上述组件的api,日常使用和替换组件的时候都会非常麻烦。封装之后,内部实现可以随意替换,外部组件不受影响
2.3.2 Repository
- 作用:公开数据操作方式
- 所有的数据方式都在repository中进行,不仅仅是访问网络。常见的一种情况是,在登录成功在Activity的成功回调中缓存用户信息,token等
- 如果在Activity中进行缓存操作,那么就相当于Activity与DataSource直接对话。可以这样做,但是不符合规范,我相信任何有点规模的框架,在Activity与DataSource之间肯定存在中间层
- 所以除了访问网络外,缓存,数据库等操作也要在Repository中管理
- 作用:屏蔽数据源
- 对于Repository的上层组件,不需要知道具体的数据源,只要调用方法即可
- 比如:UserPository 公开方法 getToken() ,对于上层组件,不需要知道getToken()方法是从哪里获取的数据,只需要接受结果即可
- 作用:处理一定的业务逻辑
- 比如:某列表请求成功后,要求缓存到数据库中。缓存逻辑就要放到Repository中处理,对于上层组件来说,它们不需要知道缓存相关的内容,只要拿到返回的List。
- 注意:不要对数据进行变换
- 某些情况下,从网络获取的数据不能直接使用,要对数据进行二次变换 UI才可以使用。这种情况就不适合放到Repository中
- 比如:获取课程列表,针对场景A进行一定程度的数据变换,如果直接在Repository中处理。那么方法返回的将会是变换后的数据,只有场景A适用。如果场景B可能也需要展示课程列表,使用针对场景A处理的数据就会不适用
- 所以类似这种情况,要在Repository之外处理,Repository返回的应该是无关业务的纯粹数据
2.4 网域层
2.4.1 UseCase(可选)
在Repository 和 ViewModel中都提到过,不适合处理的一些情况需要拆分,这部分谁都不适合处理的逻辑就交给UseCase实现。
为什么说它是可选的呢,移动端大部分场景功能比较简单,没有复杂的逻辑,在固定层次中已经拆分的很好,如果把UseCase也当作固定层次,每个类中都代码不多,没有必要。
UseCase可以用作:
- 承担拆分出代码,避免出现大型类
- 数据变换,合并
- 提取公共逻辑
2.4.2 DataMapper(可选)
后端比较常见,后端一般会定义三种数据模型
- xxxEntity 用于数据库模型,与数据库字段一一对应
- xxxDTO 用于接口接收参数
- xxxVO 用于接口返回数据模型
前端也可以适当引入本地模型,本地模型与设计图一一对应,进行一次隔离后,UI与本地模型的关系是非常稳定的,而且还可以屏蔽逻辑处理。
模拟场景 新闻列表场景
前端需要展示标题,服务端返回的数据是主标题,副标题,根据逻辑主副标题合起来才是前端展示的标题。
不引入本地模型
- 服务端返回的主副标题是两个字段,一般的做法是在Activity的数据回调中,完成主副标题的拼接,设置到UI
- 当业务变动,标题的展示逻辑修改,改动UI的代码
- 多处UI都使用了同样的标题逻辑,那么每一处UI代码都要变化
- 服务返回的新闻字段 可能有10个,但是新闻列表中,只需要用到3个字段,其他字段对于当前场景是无用的
- 另一种方式,在数据实体中新增一个 getMergeTitle(),在方法内部实现主副标题的拼接,这样可以决解问题,我个人也用过这方式,还是有点不足。
- 代码结构混乱,在Android中对数据Bean的定位,大多数情况仅仅用于承载数据,因为某个重要类的业务复杂,而在数据Bean添加逻辑,只做这件事的程序员才会知道,之前在Bean中添加了逻辑,用的时候去找找,其他人肯定是不会知道的,其他同事遇到同样的逻辑,除非他去翻代码,不然肯定会重新实现一遍
- 系统中核心功能,比如:课程,套餐。 类似的小逻辑点很多,现有架构没有一个很好的位置去处理类似逻辑,A习惯在Repository中处理,B习惯在ViewModel中处理,C习惯在Activity处理,写着写着代码就乱了
- 人是会忘的,时间长了自己也不知道上次是在哪里实现的,于是系统中可能又多了一段冗余代码
引入本地模型后
- UI 与 本地模型的关系稳定,除非设计图修改 本地模型不会修改
- 设计图中,列表元素有标题,摘要,封面图。那本地模型中的属性就只有标题,摘要封面图,UI与数据绑定非常简单,直接设置数据就好
- 具体的业务逻辑 在网络模型 与 本地模型转换时 就处理了,UI无需知道这个过程,UI只加载标题,却不知道标题是由主副标题合成的
- 即便是逻辑更改了,UI也不受影响
- 整个展示层(ViewModel,Activity,View) 代码将会非常的干净,因为大部逻辑 在 网络模型与本地模型转换之前 处理掉了,展示层只绑定数据就好
- 不仅仅是数据字段,颜色,字体大小,图片资源等等都可以放在本地模型
3.实践
组件化编码和单体项目是一致的,单体项目使用什么结构组件化项目也是一样的。个人感觉组件化难在项目配置,模块间通信,模块划分等问题。
3.1组件化分析
3.1.1 组件化结构划分
项目结构如图,接下来逐个解析
3.1.2 App壳工程
可以没有任何一行代码,主要也是唯一的工作是用来打包。
Demo项目中App壳工程中有三个文件:
Application()实现类- AndroidManifest.xml
- build.gradle
其中Application实现类 可以不在App壳工程中实现, AndroidManifest.xml 文件可以不写任何内容。只有build.gradle 是必须配置的
为什么这么说呢?
在多模块开发模式中,打包时有一个AndroidManifest.xml文件合并的步骤。 也很好理解,虽然开发是分开多个模块,但最终仍然是一个应用,只会有一个AndroidManifest文件,其中包含了每个模块在各自的AndroidManifest文件中声明的所有四大组件。
比如:
模块A的AndroidManifest 声明了 Activity-A-1 , Activity-A-2
模块B的AndroidManifest 声明了 Activity-B-3 , Activity-B-4
打包完成后,模块A,B的AndroidManifest 都不见了 ,应用只会存在一个AndroidManifest 文件其中包含内容: Activity-A-1 , Activity-A-2,Activity-B-3 , Activity-B-4。
因为存在合并机制:
- App壳工程中可以不配置任何组件
Application()实现类也可以不在壳工程中声明,在子模块声明也可,但是要记住只能在一个子模块中声明,多了会冲突。 为了方便 和 开发习惯 也可以在App工程配置
build.gradle不可缺少
App壳工程只用来打包 ,在build.gradle文件中 引用需要的子模块,设置打包配置就可。
3.1.3 子模块独立运行
在各种组件化文章中,都会有这样一个步骤,切换配置子模块由com.android.library 变为可独立运行的com.android.application 工程。用于开发时加载了过多没有必要的业务模块,缩减开发编译时间。
个人实践下来这个配置有点点鸡肋,项目运行至少要有两个模块:登录模块+目标业务模块,不然业务逻辑跑不通呀。
解决办法也很简单:修改app壳工程的build.gradle,只引用的需要的模块即可,同时修改AndroidManifest文件 更换一下启动页。
开关也有用 可以替换AndroidManifest,变量定义在项目根目录的 gradle.properties 中
sourceSets {
main {
jniLibs.srcDirs = ['libs']
if (mainRunAlone.toBoolean()) {
//独立运行
manifest.srcFile 'src/main/manifest/AndroidManifest.xml'
} else {
//合并到宿主
manifest.srcFile 'src/main/AndroidManifest.xml'
resources {
//正式版本时,排除manifest文件夹下的文件
exclude 'src/main/manifest/*'
}
}
}
}
---
gradle.properties:
userRunAlone=false
mainRunAlone=false
3.1.4 子模块配置
项目根目录新建module.build.gradle 子模块通用配置文件,
apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion rootProject.android.compileSdkVersion
defaultConfig {
minSdk rootProject.android.minSdkVersion
targetSdk rootProject.android.targetSdkVersion
versionCode rootProject.android.versionCode
versionName rootProject.android.versionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
viewBinding {
enabled = true
}
}
dependencies {
implementation rootProject.ext.moduleDepend.common_sdk
在子模块的build.gradle文件中只需要定义特殊配置即可,毕竟重要的是resourcePrefix, 设置资源前缀。
之前提到过AndroidManifest 文件会合并,其实打包时所有的资源都会合并,设置资源前缀后,模块内所有图片,颜色,布局等资源在声明的时候都会要求添加指定前缀。
防止资源重名导致打包失败
android {
//统一资源前缀,规范资源引用,会让编译器自动提示你不规范的命名
resourcePrefix "user_"
sourceSets {
main {
jniLibs.srcDirs = ['libs']
if (userRunAlone.toBoolean()) {
//独立运行
manifest.srcFile 'src/main/manifest/AndroidManifest.xml'
} else {
//合并到宿主
manifest.srcFile 'src/main/AndroidManifest.xml'
resources {
//正式版本时,排除manifest文件夹下的文件
exclude 'src/main/manifest/*'
}
}
}
}
}
dependencies {
implementation rootProject.ext.moduleDepend.common_database
}
3.1.5 模块初始化
模块初始化任务,需要评估项目复杂度 酌情而定。 大部分应用没有大厂应用那么复杂,不需要设计应用启动框架 可能就几个启动组件,没有依赖关系,放在主线程,子线程都可。 按照简单情况,利用路由组件 设计通信接口IAppStartup , 只有路由组件需要 提前初始化,其他初始化任务放在路由后进行。 路由支持service 设置优先级 ,也可以简单的设置任务执行顺序 如果组件非常多,那就要考虑引入,启动任务框架了。
如果为了规避应用合规审查的问题,可以把代码挪到 提示弹窗确认后运行
class App : Application() {
override fun onCreate() {
super.onCreate()
Thread {
//初始化路由
DRouter.init(this)
val list = DRouter.build(IAppStartup::class.java).getAllService()
for (service: IAppStartup in list) {
service.doWork(this)
}
}.start()
}
}
3.1.6 数据库-common-database
数据库模块 我考虑过两种方案
- 数据库独立模块,组件下沉,所有业务模块都可以引用
- 每个业务模块,各自维护数据库业务,没有公共的数据库模块,业务模块需要调用其他模块的数据库业务通过路由通信即可
我个人比较倾向方案2,更符合组件化的思想。但是有两个问题:
- 如果分开维护数据业务,一个项目中就可能存在多个数据库实例,白白浪费内存。如果把数据库实例下放到某个基础组件,那就又变成了 方案1
- 数据Bean也不好办,比如:UserBean的逻辑在user模块中,其他模块用到UserBean的相关逻辑,但又拿不到UserBean的类引用,也很麻烦
所以想想还是数据库弄成一个公共组件比较方便。
需要注意的是要在数据库组件中额外做一次封装:
- 数据封装,定义数据库专用的 数据类,
- 比如:DBUser, 其他模块在存取的时候都要一次转换
- 虽然多了一次转换,有点麻烦,但是数据隔离还是很又必要的,数据库层面变动不会对业务代码进行影响
- 接口封装
- 不要直接暴露三方数据库生成的 Dao类,提供一层接口封装,如果修改三方数据库,由于存在接口隔离 业务不会受影响
3.1.7 资源模块-common-resources
存放启动图标,主色,项目名等公共资源。 为什么存在资源模块呢? 理由很简单,应用内的主色需要统一,不可能每一个业务模块内都维护一份颜色,启动图等资源。
那么为什么不放在sdk 或 base模块呢,从功能上看大多数sdk 或base模块 会存放一些通用的封装或工具类,供业务模块使用。
项目中不仅仅存在业务模块,还存在功能模块,比如:轮播图,播放器等等。这些功能模块也需要图片,颜色等资源。
所以单独抽取出一个模块比较恰当。
3.1.8 通信模块-common-export
模块间通信,无论使用哪一个路由框架 或是 自定义路由 都会涉及到接口下沉的情况,export就是存放通信接口的。
使用路由组件跳转的时候 一般都会定义一个地址 @Route(path = "/module1/Module1Activity") 。可以把项目内所有用到的 路由地址 声明为常量 放在export模块中 统一管理。
模块间通信用到的数据bean 也可以放到export模块中,通过分包管理代码。
小结:export模块中存放通信接口,路由地址,数据bean
如果项目复杂模块间通信内容非常多,也可以把export模块进一步拆分,为每一个业务模块都设置一个对应的export模块,存放通讯代码。
比如:module_user 和 export_user,module_shop 和 export_shop
3.1.9 统一封装-common-sdk
一层功能封装,它的存在使得业务模块无需过多的封装,开箱即用。比如:baseActivity,baseFragment之类的,引用必要的三方组件。
3.1.10 Library 功能组件
通用功能被定义为library,比如:播放器,轮播图,网络请求等等。 无关业务,即插即用,任何模块任何项目都可以直接使用。
Demo提供了datasource 数据源组件,内部封装了网络请求 和 key-value缓存,它们符合无关业务,即插即用的定义。具体如下:
网络请求封装
RemoteDataSource 基于retrofit,抽象类
abstract class RemoteDataSource protected constructor(iNetworkSetting: IRemoteSetting) {
private val iNetworkSetting: IRemoteSetting
init {
this.iNetworkSetting = iNetworkSetting
}
companion object {
private val cacheMap: MutableMap<String, Retrofit?> = ArrayMap<String, Retrofit?>()
}
/**
*创建 网络请求接口
*/
fun <T> createService(clazz: Class<T>): T {
val retrofit: Retrofit = cacheMap[iNetworkSetting.baseUrl] ?: getRetrofitBuilder().build()
cacheMap[iNetworkSetting.baseUrl] = retrofit
return retrofit.create<T>(clazz)
}
/**
*获取 retrofit
*/
protected open fun getRetrofitBuilder(): Retrofit.Builder {
return Retrofit.Builder()
.baseUrl(iNetworkSetting.baseUrl)
.client(getOkhttpClient())
.addConverterFactory(GsonConverterFactory.create())
}
/**
*获取 okhttp
*/
protected open fun getOkhttpClient(): OkHttpClient {
val builder: OkHttpClient.Builder = OkHttpClient.Builder()
val interceptorList: List<Interceptor> = getInterceptorList()
if (interceptorList.isNotEmpty()) {
for (item in interceptorList) {
builder.addInterceptor(item)
}
}
val isDebug = iNetworkSetting.isDebug
if (isDebug) {
val interceptor = HttpLoggingInterceptor()
interceptor.level= HttpLoggingInterceptor.Level.BODY
builder.addInterceptor(interceptor)
}
return builder.build()
}
/**
*获取拦截器
*/
protected open fun getInterceptorList(): List<Interceptor> {
returnlistOf()
}
}
IRemoteSetting 网络请求配置接口(可根据需要自行扩展)
interface IRemoteSetting {
val isDebug: Boolean
val baseUrl:String
}
sdk模块中的具体实现 wanAndroid客户端
object WanAndroidRemote : RemoteDataSource(WanAndroidSetting()) {
private val service: ApiService bylazy{
createService(ApiService::class.java)
}
//省略部分瞎封装的代码
}
class WanAndroidSetting : IRemoteSetting {
override val isDebug: Boolean
get() = BuildConfig.DEBUG
override val baseUrl: String
get() = "https://www.wanandroid.com/"
}
抽象类RemoteDataSource 提供网络请求能力,一个项目中可能请求好几个服务器,继承RemoteDataSource 可以随意实现网络请求客户端。
Demo中只需要请求玩Android 则创建WanAndroidRemote ,有一天新出来个玩iOS,网络请求的基本模型不会被破坏,新创建WaniOSRemote 即可。
而且在WanAndroidRemote 中,根据业务需要可以随意二次封装。
但是需要注意WanAndroidRemote 属于业务 并不通用,需要存在在某个业务模块中 或 sdk模块。而不能存放在datasource 组件中。
key-value缓存封装
使用腾讯的MMKV 组件,并没有对外部直接暴露MMKV的方法,也是进行一次接口隔离,可以随时替换内部实现,业务组件不受影响。
3.2 功能分析
3.2.1 登录
逻辑:
- 账号密码登录
- 登录成功跳转首页
- 登录成功把用户信息保存到数据库,账号密码存储到kv缓存,下次进入应用自动填写账号密码
实现涉及组件:
- repository
- viewModel
- Activity
登录是想要表达数据逻辑编写的克制
登录的核心逻辑有两个,保存用户信息到数据库,账号密码存储到kv缓存。都是数据相关的操作 也是很常见的需求。
经常见的写法是,在Activity的成功回调中,使用sharedpreferences 或 其他kv组件 保存账号密码。这样做很方便,符合开发和思维习惯, 。
面对这个需求自然而然的会想到:哦~ 登录后要做xxx,在Activity登录监听中 完成xxx。
这样做可以 但是不合符规范,需要克制。
原因:Activity不做逻辑操作,更何况是明晃晃的数据存储,直接引用sharedpreferences 工具类进行存储
提起理论大家都知道,但是开发的时候就怎么方便怎么来了,所以说需要克制。
数据操作应该在Reposiretory中处理,判断登录接口成功之后,进行数据库存储,KV缓存。
同时Reposiretory 提供读取 账号密码的方法供外部调用,数据与UI完全隔离。Activity中的代码非常干净,简单明了。
class LoginRepository : ILoginApi {
companion object {
private const val TAG = "LoginRepository"
private const val CACHE_USERNAME = "cache_username"
private const val CACHE_PASSWORD = "cache_password"
}
private val remoteDataSource = WanAndroidRemote
private val userDao = UserDaoImpl()
private val cacheDataSource = CacheDataSource
override suspend fun login(userName: String, password: String): Flow<UIResult<UserEntity>> {
//省略代码
returnflowWrapObject{
//网络请求
val uiResult = remoteDataSource.requestObject(parameterCollector)
if (uiResult.status == UIResult.Status.Success) {
val userEntity = uiResult.data
//数据库存储
userEntity?.let{
val dbUser = DBUser(it.id,it.name)
userDao.add(dbUser)
Log.d(TAG, "保存到数据库成功")
val user = userDao.getOne(it.id)
Log.d(TAG, "数据库里的用户信息:${user}")
}
// KV 缓存
cacheDataSource.putString(CACHE_USERNAME, userName)
cacheDataSource.putString(CACHE_PASSWORD, password)
}
return@flowWrapObject uiResult
}
}
override fun getCacheUserName(): String {
return cacheDataSource.getString(CACHE_USERNAME)
}
override fun getCachePassword(): String {
return cacheDataSource.getString(CACHE_PASSWORD)
}
class LoginActivity {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//...省略部分代码
modelLogin.onLoginResult.observerWithResult(this, this, true, onSuccess ={
toast("登录成功")
DRouter.build("/main/home").start()
})
mBinding.etUserName.setText(modelLogin.getCacheUserName())
mBinding.etPassword.setText(modelLogin.getCachePassword())
}
}
3.2.2 注册
逻辑:
- 输入账号,密码,重复密码进行注册
实现涉及组件:
- repository
- viewModel
- Activity
- RegisterView
user_activity_register.xmluser_sub_register_view.xml
注册想要讨论当页面元素复杂时一种拆分方式
登录中 Activity 是常见方式的编写,view管理,数据绑定都在Activity中进行。
注册把 Activity内所有的view全部交给自定义view RegisterView 管理。
user_activity_register.xml 只声明 RegisterView
<?xml version="1.0" encoding="utf-8"?>
<com.example.module.user.register.view.RegisterView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
>
<!-- <include layout="@layout/user_sub_register_view"/>-->
</com.example.module.user.register.view.RegisterView>
其他子元素 如:输入框 ,按钮 等拆分到user_sub_register_view.xml 。使用merge标签不会增加层及。
<?xml version="1.0" encoding="utf-8"?>
<!--LinearLayoutCompat-->
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/et_user_name"
style="@style/user_style_edit"
android:hint="请输入用户名"
android:inputType="text"
android:text="shifu"
/>
<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/et_password"
style="@style/user_style_edit"
android:hint="请输入密码"
android:inputType="textPassword"
android:text="123456"
/>
<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/et_re_password"
style="@style/user_style_edit"
android:hint="请再次输入密码"
android:inputType="textPassword"
android:text="123456"
/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_register"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="80dp"
android:text="注册" />
</merge>
在RegisterView 的java 或 kotlin代码中 实现逻辑。 分成两个xml文件是因为项目中使用viewBinding的不得已。
如果不用viewBinding 正常定义xml即可,在RegisterView 的onFinishInflate()中 **findViewById()** 绑定子view。
class RegisterView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : LinearLayoutCompat(context, attrs) {
private lateinit var mBinding: UserSubRegisterViewBinding
var eventListener: RegisterViewEventListener? = null
override fun onFinishInflate() {
super.onFinishInflate()
mBinding = UserSubRegisterViewBinding.inflate(LayoutInflater.from(context), this)
mBinding.btnRegister.setOnClickListener{
val userName = mBinding.etUserName.text.toString().trim()
val password = mBinding.etPassword.text.toString().trim()
val rePassword = mBinding.etRePassword.text.toString().trim()
eventListener?.onSendRegisterEvent(userName, password, rePassword)
}
}
interface RegisterViewEventListener {
fun onSendRegisterEvent(userName: String, password: String, rePassword: String)
}
}
因为view代码从activity中拆分出去,产生了通信问题,根据需要定义通信接口。在注册情况下 只有触发登录事件时需要通信。
拆分后的Activity内部只需要持有,viewModel 和 view 对象 以及各种监听,连接UI与数据。
class RegisterActivity : BaseActivity(), RegisterView.RegisterViewEventListener {
private lateinit var mBinding: UserActivityRegisterBinding
private lateinit var registerModel: RegisterViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = UserActivityRegisterBinding.inflate(layoutInflater)
setContentView(mBinding.root)
mBinding.root.eventListener = this
registerModel = ViewModelProvider(this)[RegisterViewModel::class.java]
registerModel.onRegisterResult.observerWithResult(this, this, true, onSuccess = {
toast("注册成功")
finish()
})
}
override fun onSendRegisterEvent(userName: String, password: String, rePassword: String) {
registerModel.register(userName, password, rePassword)
}
}
3.2.3 首页列表
逻辑:
- 登录后进入首页,展示分页列表
实现涉及组件:
- repository
- useCase
- dataMapper
- viewModel
- Activity
HomeArticleList
首页列表也是当页面元素复杂时的一种拆分方式 和注册不同的是,注册是整个页面全部拆出去,首页列表是把页面再次划分为一个个小功能
Demo中的首页很简单 只有一个分页列表的功能,自定义RecyclerView HomeArticleList ,其内部实现:
- recyclerView的初始化
- 加载更多
- 分页逻辑处理
- 使用接口与外部通信
代码如下:
class HomeArticleList @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {
private var pageIndex = 0
private val mAdapter by lazy { HomeArticleAdapter() }
var onEventListener: OnArticleListEventListener? = null
override fun onFinishInflate() {
super.onFinishInflate()
adapter = mAdapter
//设置 layoutManager
layoutManager = LinearLayoutManager(context)
//设置分割线
val itemDecoration =DividerItemDecoration(context, DividerItemDecoration.VERTICAL)
itemDecoration.setDrawable(ColorDrawable(ContextCompat.getColor(context, R.color.main_color_home_article_list_divider)))
addItemDecoration(itemDecoration)
// 设置加载更多监听事件
mAdapter.loadMoreModule.setOnLoadMoreListener {
loadData()
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
//加载第一页数据
loadData()
}
private fun loadData() {
pageIndex = pageIndex.plus(1)
onEventListener?.onLoadMoreEvent(pageIndex)
}
/**
* 绑定网络数据,处理分页逻辑
*/
fun bindData(page: PageEntity<ArticleMapper>?) {
page?.run {
if (pageIndex == 0) {
if (list.isEmpty()) {
// TODO: 展示空布局
} else {
mAdapter.setNewInstance(list)
}
} else {
mAdapter.addData(list)
if (pageIndex > pageCount) {
//没有更多数据了
mAdapter.loadMoreModule.loadMoreEnd()
} else {
//有下一页数据
mAdapter.loadMoreModule.loadMoreComplete()
}
}
}
}
/**
* 加载更多失败
*/
fun loadMoreFail() {
mAdapter.loadMoreModule.loadMoreFail()
}
/**
* 通信接口
*/
interface OnArticleListEventListener {
fun onLoadMoreEvent(currPage: Int)
}
}
分页列表逻辑拆分到自定义view后,activity中只有数据与UI通信的接口,都是胶水代码,如下:
class HomeActivity : BaseActivity(), HomeArticleList.OnArticleListEventListener {
private lateinit var mBinding: MainActivityHomeBinding
private lateinit var modelHome: HomeViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = MainActivityHomeBinding.inflate(layoutInflater)
setContentView(mBinding.root)
mBinding.rvList.onEventListener = this
modelHome = ViewModelProvider(this)[HomeViewModel::class.java]
//网络请求回调
modelHome.onQueryArticleListResult.observerWithResult(this, this,
onSuccess = {
mBinding.rvList.bindData(page = it)
}, onError = { code: Int, msg: String ->
toast(msg)
mBinding.rvList.loadMoreFail()
})
}
/**
* 列表加载数据
*/
override fun onLoadDataEvent(currPage: Int) {
modelHome.queryArticleList(currPage)
}
}
—手动分隔线—
Demo中列表UI如下:很简单 标题 + 摘要, UI 定义两个TextView实现
标题可以使用网络数据返回的title 直接展示到UI上, 但是摘要并不能直接使用,它是由作者 和 发布时间两个属性构成。 同时作者 还存在 :直接作者 和 分享者 两种情况
因为UI使用两个TextView实现,所以摘要存在 判断 和 字符串拼接的数据逻辑。 有的同学会说 我不拼接,就用多个TextView实现,这样会导致UI层级变的复杂,同时要控制TextView的展示隐藏,依然存在逻辑处理。
按照Repository - viewmodel - Activity - view 的层级 谁去处理 这个逻辑判断呢? (🐶俺一直都是 )一般情况直接在RecyclerView.Adapter 中处理了。可以,但是不合适
RecyclerView 属于UI层,逻辑不应该放在UI处理。 在有点吹毛求疵的性能说法,RecyclerView每次绑定数据的时候都要进行逻辑判断 不是浪费性能么
当数据交给RecyclerView时,应该是处理好的,RecyclerView直接展示就好。
那么谁去处理列表数据呢?
-
Repository
- 不太合适,我对Repository 的定义,不允许它做数据变换。因为数据变换和UI是关联的,可能只适应一处UI,同样的文章列表,换个地儿就需要另一种样式了, 比如:
- Repository 定义 queryArticleList() 获取文章列表
- 在首页使用 直接修改 queryArticleList() 处理首页UI逻辑
- 之后 在B页面 也需要文章列表 与 首页UI逻辑不一样。 原本的情况是 直接调用 queryArticleList() 就能满足,由于它已经针对首页进行了一次数据变换,其中的逻辑对于B页面是多余的。
- 所以应该保持数据的纯净,不耦合UI逻辑
-
viewmodel
- 如果UI逻辑简单,可以在ViewModel 中处理,但是逻辑复杂和有重用需求的时候 就不合适了
-
Activity 和 view 就不用说了 不能处理逻辑
-
网络模型与UI模型的冲突
- UI有摘要的概念
- 网络模型中 并没有摘要的概念
- UI展示的摘要 是有 网络模型中 多个属性 拼接而成的。
- 因为预先处理逻辑,那么拼接后形成的 摘要 肯定要 对应一个 summary变量。 原本的网络模型中是没有summary变量的。
- 在网络模型中所以定义UI需要的属性 也是不好的,别人接手你的代码 不去看逻辑 是不知道这个属性的作用的,时间长了 自己也会忘
经过以上的讨论,发现原有项目结构无法很好的处理现有问题,那就需要引入新成员:usecase 和 datamapper (mmp 铺垫了一堆 终于写出主角了)
// usecase的核心逻辑 val mapperList =mutableListOf<ArticleMapper>() for (item: ArticleEntity in list) { val author = if (item.author == "") item.shareUser else item.author val summary = "作者:${author}\t 发布时间:${item.niceShareDate}" val mapper = ArticleMapper(item.id, item.title, summary, item.link) mapperList.add(mapper) } mapperPage.list = mapperList // adapter class HomeArticleAdapter : BaseQuickAdapter<ArticleMapper, BaseViewHolder>(R.layout.main_item_home_article),LoadMoreModule { override fun convert(holder: BaseViewHolder, item: ArticleMapper) { holder .setText(R.id.item_tv_title, item.title) .setText(R.id.item_tv_summary, item.summary) } }看代码和理论预期的效果一直,adapter直接设置数据即可。
DataMapper 真的很好用! 真的很好用!!
网络模型 从 服务端来, 本地模型 从 UI模型来。 前端开发应该经常见 网络模型 与UI模型 有出入的情况 或者 需要维护 UI状态, 因为没有位置存放 UI状态 不得不放在网络模型中,比如:列表的选中状态。
有了DataMapper 后 UI相关的数据都可以扔到DataMapper中
3.2.4 小结
数据逻辑 和 UI逻辑从Activity拆分后,Activity就不能当作页面看了,它并不承担实现代码,只有由于平台特性,实现一个页面离不开Activity而已。
一个Activity代表一个场景,这个场景下有N个View元素,需要对应的N中数据。Activity仅仅作为中间人 或 管理者 连接View 与 数据。
用MVC模型解释,当view 和 数据拆分出去后,Activity才是一个干净的C。
4.参考
应用架构指南 | Android 开发者 | Android Developers (google.cn)
被误解的 MVC 和被神化的 MVVM - 掘金 (juejin.cn)
关于Android架构,你是否还在生搬硬套? - 掘金 (juejin.cn)
神奇宝贝 眼前一亮的 Jetpack + MVVM 极简实战 - 掘金 (juejin.cn)
我从 Android 官方 App 中学到了什么? - 掘金 (juejin.cn)
【Jetpack篇】协程+Retrofit网络请求状态封装实战 - 掘金 (juejin.cn)
玩Android的各种版本,包括单体版(kotlin+协程+jetpack+MVVM)、组件化版、Compose版...-玩Android - wanandroid.com