使用DSL+MVI+SharedFlow的方式写了一个登录页面,这代码以后就这么敲了

3,992 阅读16分钟

在前两篇文章中,我们分别使用DSL自定义了一个弹框和drawable控件,大致的已经熟悉了DSL的语法点,下面是前两篇文章的链接

在这一篇文章里面将会进一步增加难度,在之前的基础上继续使用DSL开发一个登录页面,我们知道在安卓里面想要开发一个页面,必不可少的就是要写layout文件,然后通过setContentView设置进去,这种传统的布局方式虽说有它的优势之处,比如稳定,布局可预览等,但如今更多的是对这种方式吐槽,甚至是抱有一些抵触情绪,我想原因可能包括以下几点

  • 在setContentView里面会对layout文件进行xml解析,将解析出来的节点生成一个个View绘制到界面上,这是个极度消耗时间与内存的过程
  • 代码中会存在大量findViewById的模版代码,尽管诸如Butterknife,Viewbinding以及kotlin-android-extension这些库已经解决了大量模版代码的问题,但始终需要与控件id绑定在一起,id如果变了,就会编译报错
  • 编写layout文件比较耗时,降低了开发效率

除此之外,实际开发当中我们还要注意layout文件里面View树的层级不要过深,谷歌也推荐使用约束布局来绘制界面减少层级达到性能,但这些也只是减缓上述说的性能开销,并不能说彻底解决,想要彻底解决这个问题,我们不得不考虑是否有其他方案可以取代这种传统UI的开发方式

取代传统编写xml文件的UI开发方式

可能到了这里,大家脑海当中肯定立马出来了一个词,就是Jetpack Compose,没错,使用Compose开发UI的确是谷歌近几年鼓励推荐的,大家也注意到了我们Android Studio近几次的版本或多或少都在优化Compose相关的功能,的确可以去尝试一下,但我想大多数项目想要从传统Ui的开发方式转变到Compose还是需要很大的人力时间成本的,所以还得找其他方案,首先让我们先看一下setContentView里面

image.png

它其实是有重载方法的,除了使用layout文件的id作为参数,它还支持直接使用View作为入参,也就是说整个页面我们可以当作是在开发一个自定义View一样,而我们传统的自定义View方式是新建一个类,继承某一个父View,这样的做法没问题,但不利于扩展,也有一定的维护成本,想要让一套代码可以支持搭建任意一种布局样式,我们就得使用DSL的方式去自定义我们的布局,所以第一步就是先定义一个顶层函数createLayout,接收一个函数类型参数,这个参数就是拿来自定义View的,然后这个View就作为返回值,用来当作setContentView的参数,函数定义如下

image.png

页面的入口代码就变成这个样子 image.png

然后我们就可以在createLayout里面一步步地将我们的视图元素塞进去了

分析页面元素

在开发之前,我们首先看一下我们要做的登录页面的效果图

image.png image.png

就像写布局文件一样,在使用DSL写布局之前,我们首先要确定我们的根布局,从效果图上看,我们的根视图是一个垂直方向的线性布局,从上到下分别是标题的TextView,副标题的TextView,用户名输入框EditText,密码输入框EditText,以及登录按钮Button,而EditText里面还有一个清空按钮,两者是在一个水平方向的线性布局里面,用一个树状图表达它们的层级关系

image.png

根据层级关系,我们的根视图就是LinearLayout,所以我们第一步就先创建最外层的LinearLayout

创建根视图

我们再定义一个顶层函数linearLayout,表示这个函数就是用来生成线性布局视图的,代码如下

image.png
  • 第一个参数不用说,创建视图必传的context
  • 第二个参数initJob,它是一个带接收者的lambda,接收者为Linearlayout,即用来初始化一些LinearLayout属性,比如宽高,内边距,外边距,背景色等等
  • 第三个参数childView,因为线性布局底下有多少子视图是不固定的,所以要让这个参数可变,可以是一个子视图,也可以是若干个

线性布局的视图定义好了,我们顺便在linearlayout的基础上再创建两个函数,分别代表垂直线性布局和水平线性布局,这样在调用线性布局的时候,就不用每次去定义orientation属性了,两种线性布局的代码如下

image.png

这里有个语法点,可变参数childView在当作参数传递到其他函数里面去的时候,变量前必须要带上*,这样编译器才会把它重新复制到一个新的数组中传递过去,不然仅仅传递的是数组本身的Object

要不是编译器提示我带上*,我还真不知道,学到了学到了,言归正传,我们把定义好的线性布局传到createLayout函数中去

image.png

因为整个登录页面的布局是垂直方向的,所以这里用的是verticalLinearlayout,顺便给它加了点边距,让元素不要贴边,这里我们可以简化一下,像上述LayoutParam,基本每个元素都要设置一遍,类似于xml布局里面给每个元素设置layout_width和layout_height属性,所以我们可以使用几个函数,把常用的宽高设置放在函数里面,调用的时候只需要调用函数就好了,类似于Compose里面的Modifier类的fillMaxSize,fillMaxWidth,size函数,我们这边的函数定义如下

image.png

这样这里就可以使用fillMaxWidth函数来取代上面的宽高设置,简化后的代码如下

image.png

到这里整个页面的根视图就绘制完毕了,效果图就不展示了,反正大家都知道目前只是一个大白板,接下去开始开发里面的子视图

主标题与副标题

主标题与副标题,其实就是两个Textview,可以放在一起讲,老规矩先要定义一个函数来生成TextView,代码如下

image.png

然后对TextView进行初始化

image.png

接着将设置好的Textview加到我们布局里面去

image.png

很简单,主标题与副标题就设置好了,我们运行下代码看看效果

image.png

用户名与密码编辑框

编辑框部分稍微复杂一点了,我们先分析一下需要注意哪几点

  1. 从上面的树状图,编辑框是一个水平线性布局里面包着一个EditText和ImageView,ImageView常驻在线性布局右边,垂直居中
  2. 编辑框布局的样式是一个圆角带灰色边框的drawable,这个可以用上一篇文章中的drawable控件来实现

我们先完成线性布局部分

image.png

一个宽度满屏,高度自适应的并且带圆角与边框的布局就完成了,我们将这个布局加到页面中去

image.png

运行后的效果图如下

image.png

嗯?怎么感觉没啥变化?没变化就对了,因为我们的编辑框布局是高度自适应的,里面没有视图填充自然是看不见的,要把编辑框放进去才可以,所以得先定义一个函数生成EditText

image.png

再定义一个函数去初始化EditText的属性

image.png

把EditText设置到编辑框布局中去

image.png

现在再run一下,编辑框就出来了

image.png

编辑框还有个清空按钮,同样我们先创建个生成ImageView的函数

image.png

然后再对这个ImageView做初始化操作

image.png

再添加到我们的编辑框布局里面

image.png

再运行一遍,我们的编辑框布局里面就带上了清空按钮了

image.png

用户名的编辑框完成了,密码的编辑框基本一样,这里直接上代码

image.png image.png image.png

再同样把密码编辑框加到我们的布局里面

image.png

我们的密码编辑框就加好了,运行一下看看效果

image.png

登录按钮

还剩下最后一个元素登录按钮,这是一个Button,所以我们同样先定义一个函数用来生成Button

image.png

然后再对按钮做初始化

image.png

按钮就这样完成了,我们把按钮添加到布局里面后看下效果

image.png image.png

按钮是出来了,但是我们想要的样式还没有,所以跟编辑框布局一样,我们在按钮里面也使用DSL的drawablw控件加上我们想要的样式,代码如下

image.png

我们再运行一遍看看效果

image.png

半途总结

我们的布局已经绘制完毕了,整个过程下来给我的感觉用DSL绘制布局有优势也有劣势

优势

  • 代码简洁,结构清晰,视图树可以清晰的反映在代码上
  • 声明式ui的构建方式,只需关心视图本身的属性,无需关心视图如何绘制测量排版
  • 通过定义各种顶层函数扩展函数,减少重复代码,提升开发效率

劣势

  • 无法预览效果,必须通过运行代码才知道布局绘制的是否正确
  • 遇到一些第一次使用的控件,必须定义新的函数,才可以在View层使用

如何开发视图逻辑

目前为止我们的静态页面完成了,但是作为一个完整的页面,还必须要对用户的操作作出响应,这个响应通常体现在界面元素上的变化,我们也管这些变化叫视图逻辑,比如我们的登录页面,它的视图逻辑包括但不限于以下几点

  1. 编辑框在没有内容输入的时候,清空按钮应该是隐藏状态
  2. 按钮初始状态应该是置灰不可点击,当满足一定条件才可以变成高亮可点击状态
  3. 点击清空按钮,对应编辑框内容清空
  4. 点击登录按钮,发起登录请求

任何一个登录页面应该都会有以上四点交互逻辑吧,那么我们该如何在现在的DSL的结构下去实现这些逻辑呢?这个问题其实在刚开始写页面的时候没怎么去想,琢磨着直接把界面上的元素声明为成员变量,在DSL的布局里面对这些成员变量赋值,想要改变某一个View的状态的时候,改变它的私有属性就好了,代码如下

lateinit var etUserName:EditText
lateinit var etPassword:EditText
lateinit var clearUserName:ImageView
lateinit var clearPassword:ImageView
lateinit var loginBtn:Button

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(createLayout {
        verticalLinearlayout(this, {
            ...省略...
        }, { 省略}, { 省略}, {省略 }, { 省略},{
            loginBtn = generateButton()
            loginBtn
        })
    })
}

private fun generateUserEditRoot(): LinearLayout {
    return horizontalLinearlayout(this, {
        ...省略...
    }, {
        etUserName = generateUserEdit()
        etUserName
    }, {
        clearUserName = generateUserEditClear()
        clearUserName
    }).also {
        ...省略...
    }
}

private fun generatePasswordEditRoot(): LinearLayout {
    return horizontalLinearlayout(this, {
        ...省略...
    }, {
        etPassword = generatePasswordEdit()
        etPassword
    }, {
        clearPassword = generatePasswordEditClear()
        clearPassword
    }).also {
        ...省略...
    }
}

还是习惯性的把传统UI那套思维带进来了是不,这样的做法的确可以实现上述所描述的视图逻辑,但这样做不但破坏了声明式UI的结构,如果要访问的View变多了,大量的赋值语句和改变View私有属性的语句也会让代码变得难以维护,降低了代码可读性,所以我们需要换一种设计思路,我们重新讲目光回到代码中,我们发现代码中有个特点,那就是每一个独立的View,都会有一个属性它的初始化函数

image.png

这样做的目的当然一个是需要返回一个View然后塞给LinearLayout#addView里面将视图显示出来,另一个原因是我们可以在这些初始化函数里面做一些视图相关的操作,比如发送事件,或者接收外界传来的事件改变自身的状态,而接收事件以及分发这些状态的事情,我们有一个公共的逻辑层去处理

image.png

像图中描述的那样,拥有唯一数据来源,事件由下往上流,状态由上往下流的单向数据流方向,以及View通过接收状态去完成刷新属性,这样的架构模式我们现在有一个统一的名称--MVI

使用MVI的方式开发

首先我们需要确定好页面需要有哪几个状态,我们这里有创建一个LoginState,这个类就是去管理这些状态

image.png

我们先去完成按钮的高亮逻辑,按钮是监听到用户名与密码编辑框同时都有内容的时候才会高亮,由于用户名编辑框与密码编辑框都是在各自的lambda表达式做初始化工作,互相都访问不了对方,它们发送事件只能发送自己本身输入的内容,无法转化为按钮需要监听的状态,所以需要把它们输入的内容状态上移,维护在一个公共的类里面

image.png

页面中维护仅有的唯一的LoginParam,编辑框发送事件的时候,将输入的内容封装在LoginParam里面并将它发送出去,这样逻辑层接收的事件就同时包含着用户名与密码编辑框输入的内容了,代码实现如下

image.png

loginViewModel是我们的逻辑层,它负责接收事件,而处理数据,分发状态的工作,我们交给SharedFlow,因为每一个View都会去订阅事件,多个订阅者的场景SharedFlow比较合适,代码如下

image.png

然后我们在之前初始化按钮的位置,监听来自LoginViewModel分发过来的状态值,当状态值是true就高亮按钮,按钮可点击,当状态是false的时候,就把按钮置灰,按钮不可点,代码如下

image.png

按钮的初始状态也改为了置灰,这样我们按钮与编辑框之间的视图逻辑就完成了,我们看下效果

a_2.gif

接着是我们的清空按钮,我们希望清空按钮在编辑框没有内容的时候是隐藏状态,有东西输入的时候才显示,那么这个也很简单,因为我们在LoginViewmodel里面已经有接收编辑框传来的事件,我们只需要将这些事件分别分发给用户名与密码编辑框里的两个清空按钮,剩下的就是两个清空按钮各自去监听状态刷新ui了,我们更新下loginViewModel的update函数

image.png

之后我们将两个清空按钮初始状态变成不可见,并监听传来的状态刷新ui

image.png image.png

我们再运行一遍代码看看效果如何

a_3.gif

清空按钮的刷新逻辑做好了,但是它本身的功能还没完成,清空按钮是点了以后会将输入框里的内容全部清空,那这里也就是要将这个清空事件发送给LoginViewModel,然后在里面针对是从哪个清空按钮发来的事件判断需要去更新哪个编辑框的UI状态,将对应状态分发出去,我们在LoginViewModel里面增加一个函数

image.png

我们看到userEdit为true的时候,表示从用户名编辑框的清空按钮传来的事件,所以我们将清空用户名编辑框的状态分发出去,同理,当userEdit为false的时候,将清空密码编辑框的状态分发出去,我们在编辑框那边监听这些状态的代码如下

image.png image.png

当清空了编辑框里面内容的时候,同时也向LoginViewModel发送了一个update的事件,目的也是去刷新按钮与清空按钮本身的ui状态,这个也就是我们这个架构的特点,元素的事件转化为其他元素的状态,元素的状态产生新的元素的事件,这也帮助我们在DSL这样的布局里面,虽然元素之间零耦合关系,但我们还是可以通过一个逻辑层比如ViewModel,实现元素之间的交互逻辑。话题回到代码上,现在我们已经完成编辑框监听清空内容的状态,我们最后一步给清空按钮添加上点击动作并发送清空事件,代码如下

image.png image.png

我们运行下代码看下效果

a_4.gif

现在整个页面的交互逻辑只剩下最后一个,那就是点击登录按钮发送登录请求,根据获取到的不同结果让页面做出不同响应,我们这里省略登录请求的过程,直接将密码长度大于6作为登录成功的判断条件,否则登录失败,现在我们在LoginViewModel中添加login函数,实现上述逻辑

image.png

然后在按钮上添加上点击事件

image.png

而我们这个登录状态的监听,由于是整个页面的监听,所以不用放在任何一个元素里面,可以直接放在布局之外的地方

image.png

而doError()跟doSuccess(),分别是针对请求响应失败与成功作出的操作,我们这边直接使用DSL弹出一个框,告诉用户登录失败的原因是参数错误,或者告诉用户登录成功

image.png image.png

效果图如下

a_5.gif

总结

整个登录页面已经开发完成了,虽然是一个很简单的登录界面,但是通过这篇文章,我们还是可以学到一些东西

  • 如何在不写xml布局的前提下,使用DSL搭建一个页面
  • 如何使用MVI的架构模式,在DSL内部结构中,实现元素响应式更新状态

希望可以通过这篇文章,可以给一些不太愿意写xml布局,或者想尝试声明式UI而项目又不太方便转Compose或者Flutter的小伙伴们提供一个新的思路