Android-架构实践

115 阅读20分钟

1.前言

老生常谈的架构实践,借鉴了很多前辈的东西,应该是没什么新东西吧,算是个人实践瞎搞 😂。

有兴趣就看看,文章共分理论与实践两部分,理论部分根据下面的层次架构图,分析每一层每一个组件的作用。

实践部分使用WanAndroidApi 搞了个组件化项目,分析组件化项目的结构并叙述每个组件存在的理由。

结合理论写了三个小功能,登录,注册,首页列表,每个功能的写法稍微有点点不同,讨论页面复杂时如何拆分代码。

项目地址 android-architecture(gitee.com)

2.理论

2.1应用架构层次图

参考Google最新的架构层次图,做了一点小小的改动。下文将会每层每个组件逐个分析

Untitled.png

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结构。

根据文档,它的作用应该是:

  1. 不跟随Activity因配置变化而重建,替代Activity持有View使用的数据,当Activity配置变化重建时数据依然存在于内存中
  2. 多个fragment间,在Activity范围内共享数据
  3. 配合liveData使用
  4. 配合kotlin 协程使用
  5. 配合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

  1. 作用:公开数据操作方式
    1. 所有的数据方式都在repository中进行,不仅仅是访问网络。常见的一种情况是,在登录成功在Activity的成功回调中缓存用户信息,token等
    2. 如果在Activity中进行缓存操作,那么就相当于Activity与DataSource直接对话。可以这样做,但是不符合规范,我相信任何有点规模的框架,在Activity与DataSource之间肯定存在中间层
    3. 所以除了访问网络外,缓存,数据库等操作也要在Repository中管理
  2. 作用:屏蔽数据源
    1. 对于Repository的上层组件,不需要知道具体的数据源,只要调用方法即可
    2. 比如:UserPository 公开方法 getToken() ,对于上层组件,不需要知道getToken()方法是从哪里获取的数据,只需要接受结果即可
  3. 作用:处理一定的业务逻辑
    1. 比如:某列表请求成功后,要求缓存到数据库中。缓存逻辑就要放到Repository中处理,对于上层组件来说,它们不需要知道缓存相关的内容,只要拿到返回的List。
  4. 注意:不要对数据进行变换
    1. 某些情况下,从网络获取的数据不能直接使用,要对数据进行二次变换 UI才可以使用。这种情况就不适合放到Repository中
    2. 比如:获取课程列表,针对场景A进行一定程度的数据变换,如果直接在Repository中处理。那么方法返回的将会是变换后的数据,只有场景A适用。如果场景B可能也需要展示课程列表,使用针对场景A处理的数据就会不适用
    3. 所以类似这种情况,要在Repository之外处理,Repository返回的应该是无关业务的纯粹数据

2.4 网域层

2.4.1 UseCase(可选)

在Repository 和 ViewModel中都提到过,不适合处理的一些情况需要拆分,这部分谁都不适合处理的逻辑就交给UseCase实现。

为什么说它是可选的呢,移动端大部分场景功能比较简单,没有复杂的逻辑,在固定层次中已经拆分的很好,如果把UseCase也当作固定层次,每个类中都代码不多,没有必要。

UseCase可以用作:

  1. 承担拆分出代码,避免出现大型类
  2. 数据变换,合并
  3. 提取公共逻辑

2.4.2 DataMapper(可选)

后端比较常见,后端一般会定义三种数据模型

  1. xxxEntity 用于数据库模型,与数据库字段一一对应
  2. xxxDTO 用于接口接收参数
  3. xxxVO 用于接口返回数据模型

前端也可以适当引入本地模型,本地模型与设计图一一对应,进行一次隔离后,UI与本地模型的关系是非常稳定的,而且还可以屏蔽逻辑处理。

模拟场景 新闻列表场景

前端需要展示标题,服务端返回的数据是主标题,副标题,根据逻辑主副标题合起来才是前端展示的标题。

不引入本地模型

  1. 服务端返回的主副标题是两个字段,一般的做法是在Activity的数据回调中,完成主副标题的拼接,设置到UI
  2. 当业务变动,标题的展示逻辑修改,改动UI的代码
  3. 多处UI都使用了同样的标题逻辑,那么每一处UI代码都要变化
  4. 服务返回的新闻字段 可能有10个,但是新闻列表中,只需要用到3个字段,其他字段对于当前场景是无用的
  5. 另一种方式,在数据实体中新增一个 getMergeTitle(),在方法内部实现主副标题的拼接,这样可以决解问题,我个人也用过这方式,还是有点不足。
    1. 代码结构混乱,在Android中对数据Bean的定位,大多数情况仅仅用于承载数据,因为某个重要类的业务复杂,而在数据Bean添加逻辑,只做这件事的程序员才会知道,之前在Bean中添加了逻辑,用的时候去找找,其他人肯定是不会知道的,其他同事遇到同样的逻辑,除非他去翻代码,不然肯定会重新实现一遍
    2. 系统中核心功能,比如:课程,套餐。 类似的小逻辑点很多,现有架构没有一个很好的位置去处理类似逻辑,A习惯在Repository中处理,B习惯在ViewModel中处理,C习惯在Activity处理,写着写着代码就乱了
    3. 人是会忘的,时间长了自己也不知道上次是在哪里实现的,于是系统中可能又多了一段冗余代码

引入本地模型后

  1. UI 与 本地模型的关系稳定,除非设计图修改 本地模型不会修改
  2. 设计图中,列表元素有标题,摘要,封面图。那本地模型中的属性就只有标题,摘要封面图,UI与数据绑定非常简单,直接设置数据就好
  3. 具体的业务逻辑 在网络模型 与 本地模型转换时 就处理了,UI无需知道这个过程,UI只加载标题,却不知道标题是由主副标题合成的
  4. 即便是逻辑更改了,UI也不受影响
  5. 整个展示层(ViewModel,Activity,View) 代码将会非常的干净,因为大部逻辑 在 网络模型与本地模型转换之前 处理掉了,展示层只绑定数据就好
  6. 不仅仅是数据字段,颜色,字体大小,图片资源等等都可以放在本地模型

3.实践

组件化编码和单体项目是一致的,单体项目使用什么结构组件化项目也是一样的。个人感觉组件化难在项目配置,模块间通信,模块划分等问题。

3.1组件化分析

3.1.1 组件化结构划分

项目结构如图,接下来逐个解析

Untitled 1.png

3.1.2 App壳工程

可以没有任何一行代码,主要也是唯一的工作是用来打包。

Demo项目中App壳工程中有三个文件:

  1. Application() 实现类
  2. AndroidManifest.xml
  3. 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。

因为存在合并机制:

  1. App壳工程中可以不配置任何组件
  2. 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

数据库模块 我考虑过两种方案

  1. 数据库独立模块,组件下沉,所有业务模块都可以引用
  2. 每个业务模块,各自维护数据库业务,没有公共的数据库模块,业务模块需要调用其他模块的数据库业务通过路由通信即可

我个人比较倾向方案2,更符合组件化的思想。但是有两个问题:

  1. 如果分开维护数据业务,一个项目中就可能存在多个数据库实例,白白浪费内存。如果把数据库实例下放到某个基础组件,那就又变成了 方案1
  2. 数据Bean也不好办,比如:UserBean的逻辑在user模块中,其他模块用到UserBean的相关逻辑,但又拿不到UserBean的类引用,也很麻烦

所以想想还是数据库弄成一个公共组件比较方便。

需要注意的是要在数据库组件中额外做一次封装:

  1. 数据封装,定义数据库专用的 数据类,
    1. 比如:DBUser, 其他模块在存取的时候都要一次转换
    2. 虽然多了一次转换,有点麻烦,但是数据隔离还是很又必要的,数据库层面变动不会对业务代码进行影响
  2. 接口封装
    1. 不要直接暴露三方数据库生成的 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 登录

逻辑:

  1. 账号密码登录
  2. 登录成功跳转首页
  3. 登录成功把用户信息保存到数据库,账号密码存储到kv缓存,下次进入应用自动填写账号密码

实现涉及组件:

  1. repository
  2. viewModel
  3. 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 注册

逻辑:

  1. 输入账号,密码,重复密码进行注册

实现涉及组件:

  1. repository
  2. viewModel
  3. Activity
  4. RegisterView
  5. user_activity_register.xml
  6. user_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即可,在RegisterViewonFinishInflate()**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 首页列表

逻辑:

  1. 登录后进入首页,展示分页列表

实现涉及组件:

  1. repository
  2. useCase
  3. dataMapper
  4. viewModel
  5. Activity
  6. HomeArticleList

首页列表也是当页面元素复杂时的一种拆分方式 和注册不同的是,注册是整个页面全部拆出去,首页列表是把页面再次划分为一个个小功能

Demo中的首页很简单 只有一个分页列表的功能,自定义RecyclerView HomeArticleList ,其内部实现:

  1. recyclerView的初始化
  2. 加载更多
  3. 分页逻辑处理
  4. 使用接口与外部通信

代码如下:

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实现

Untitled 2.png

标题可以使用网络数据返回的title 直接展示到UI上, 但是摘要并不能直接使用,它是由作者 和 发布时间两个属性构成。 同时作者 还存在 :直接作者 和 分享者 两种情况

因为UI使用两个TextView实现,所以摘要存在 判断 和 字符串拼接的数据逻辑。 有的同学会说 我不拼接,就用多个TextView实现,这样会导致UI层级变的复杂,同时要控制TextView的展示隐藏,依然存在逻辑处理。

按照Repository - viewmodel - Activity - view 的层级 谁去处理 这个逻辑判断呢? (🐶俺一直都是 )一般情况直接在RecyclerView.Adapter 中处理了。可以,但是不合适

RecyclerView 属于UI层,逻辑不应该放在UI处理。 在有点吹毛求疵的性能说法,RecyclerView每次绑定数据的时候都要进行逻辑判断 不是浪费性能么

当数据交给RecyclerView时,应该是处理好的,RecyclerView直接展示就好。

那么谁去处理列表数据呢?

  1. Repository

    1. 不太合适,我对Repository 的定义,不允许它做数据变换。因为数据变换和UI是关联的,可能只适应一处UI,同样的文章列表,换个地儿就需要另一种样式了, 比如:
    2. Repository 定义 queryArticleList() 获取文章列表
    3. 在首页使用 直接修改 queryArticleList() 处理首页UI逻辑
    4. 之后 在B页面 也需要文章列表 与 首页UI逻辑不一样。 原本的情况是 直接调用 queryArticleList() 就能满足,由于它已经针对首页进行了一次数据变换,其中的逻辑对于B页面是多余的。
    5. 所以应该保持数据的纯净,不耦合UI逻辑
  2. viewmodel

    1. 如果UI逻辑简单,可以在ViewModel 中处理,但是逻辑复杂和有重用需求的时候 就不合适了
  3. Activity 和 view 就不用说了 不能处理逻辑

  4. 网络模型与UI模型的冲突

    1. UI有摘要的概念
    2. 网络模型中 并没有摘要的概念
    3. UI展示的摘要 是有 网络模型中 多个属性 拼接而成的。
    4. 因为预先处理逻辑,那么拼接后形成的 摘要 肯定要 对应一个 summary变量。 原本的网络模型中是没有summary变量的。
    5. 在网络模型中所以定义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