稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

降Compose十八掌之『神龙摆尾』| Architecture

通过前面的一系列文章,我们已经掌握了足够的Jetpack Compose的开发基础。为了更好的在实际项目中使用Compose,我们还需要了解一下现代应用开发的架构原则,以及使用Jetpack Compose时如何更好的遵循这些原则。这篇文章将聚焦于架构原则这一话题,进行一些探讨和总结。

现代Android应用开发的架构方式

Jetpack Compose是一个声明式的UI框架,用它来开发应用程序,因此根本上仍是在做应用程序开发,所以需要遵循现代应用程序的架构原则。

一提到架构自然会想到Bob大叔的The Clean Architecture,这里面的最主要的核心思想就是分层,把不同的概念按照抽象的层次进行分离,层与层之间有特定的依赖规则,也即只能从控制层往业务逻辑依赖。分层最大的益处就是方便移植和替换,降低维护成本,这也是架构的意义所在。

图1. The Clean Architecture

对于移动应用开发,谷歌也给出比较实用的现代应用架构原则,其中有四个核心原则:

  1. 远离系统组件,系统组件(Activity,Service和Fragment等)仅能作为一个入口和必要的依赖对象,以及协调和连接不同的对象。深层次的原因是系统组件实例不可控,系统随时会重新创建实例,所以应该把对系统组件的依赖降到最低;
  2. 由数据来驱动UI,且数据最好是不可变的(Immutable data)。这个原则要求把逻辑尽可能的放在数据层而非UI层,UI层就是展示数据层,处理用户事件和UI自己的逻辑,但不应该做的业务逻辑处理。比如说新闻类应用,数据层把一坨列表传过来,UI就展示,如果列表为空,那显示加载错误,用户点击刷新就让数据层刷新数据。但不应该对列表中的数据做更新或者更改,比如说把不同的列表融合为一个,这些都是业务逻辑,应该由数据层来做。这样的好处是能让UI层尽可能的简单,方便移植,方便测试。而且这符合响应式的数据流,可以使用响应式编程范式(MVVM或者MVI);
  3. 单一数据源(Single Source Of Truth),也就是说任何数据都应该只由它的生产者来修改,其他模块只是使用不能修改,因此每一层返回的数据都应该是不可修改类型(Immutable objects);
  4. 单向数据流动(Unidirectional Data Flow),UI层展示数据,获得用户事件,调用业务逻辑层处理事件,业务逻辑层再去数据层请求新的数据,新的数据再来驱动刷新UI,而不可以业务逻辑层修改数据后一边去刷新UI,一边再去让数据层修改数据,这会导致难以调试的bug。

图2. 现代应用典型架构

下面我们围绕Jetpack Compose来深入探讨一下如何把这些架构原则落到实处。

使用Jetpack Compose落实架构原则

Jetpack Compose是声明式UI框架,所以它只能出现在UI层,用UI元素展示数据, 以及获取用户事件。其余的部分,如业务逻辑层(ViewModels)和数据层(Models and Data)都与Compose没有关系,也不应该受到Compose的影响。这就是架构分层带来的好处,不同层之间通过约定 的接口进行协作,每一层都可以用不同的技术栈去实现,不会对其他层造成影响。

图3. 应用架构中的UI层的角色

典型的项目结构

按照架构原则,一个典型的项目模块结构应该是酱紫的:

图4. 典型项目模块结构

Activity(对应着Activity实例)和AndroidApp(对应着Application实例)是应用程序的入口,可能需要在里面做一些必要的初始化工作,比如有些三方的库可能需要在Application#onCreate中去做初始化工作。ui package对应着UI层,负责UI的展示;package model对应着Model层,里面定义着供UI层使用的数据类型,以及获取 这些数据的接口;package data对应着数据层(data layer),实现着model中的接口定义。当然也可以把model与data合并成一个package,不过,单独把数据的定义和数据层的接口拿出来放在一个包里,会更清晰一些,因为UI层只需关心有哪些接口可以获得什么数据,它只需要知道model就够了。

Jetpack Compose的入口应该用一个名字为App的函数,在这里做初始化工作,比如创建数据层的实例,进行弹窗,创建导航等等。此处作为一个桥接,用以把Android的组件与Compose连接起来,创建必要的实例并把各实例协调起来。当然也可以直接把这些事情放入Activity中,但独立出来更方便测试和移植,让系统组件变得更为简单。Activity直接调用App即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AeolusApp(Modifier.fillMaxWidth())
        }
    }
}

@Composable
fun AeolusApp(
    modifier: Modifier = Modifier
) {
    AeolusTheme {
        // A surface container using the 'background' color from the theme
        Surface(
            modifier = modifier,
            color = MaterialTheme.colorScheme.background
        ) {
            CurrentLocationPermission {
                val dataContainer = DataContainerImpl(LocalContext.current)
                AeolusNavGraph(appContainer = dataContainer)
            }
        }
    }
}

创建导航

导航是把所有的UI页面组织在一起形成一个逻辑清晰的交互整体,可以参照之前的文章降Compose十八掌之『密云不雨』| Navigation来创建导航。

需要注意的就是页面应该命名为Screen,其ViewModel应该作为参数传递给Screen,Screen和ViewModel的实例创建都在导航中来完成。

ViewModel应该保持独立

ViewModel作为UI层与数据层的中间层而存在,目的是让UI层专注于数据展示。为了更好的可移植性,ViewModel不应该有平台相关的依赖,比如平台的生命周期或者Context,它只应该依赖model层。并且为了方便依赖注入,应该把Model层的数据接口对象(通常是Repository)作为参数传递给ViewModel:

1
2
3
4
5
class FavoritesViewModel(
    private val locationRepo: LocationRepository,
    private val weatherRepo: WeatherRepository
) : ViewModel() {
}

具体的Repository对象可以在导航创建ViewModel时创建,或者用依赖注入框架(如Hilt)来注入实例。

并发原则

现代的应用肯定都会用并发,无论是协程还是Flow,为了能让并发更加的结构化和可控,应该遵循以下原则:

  1. Model层的方法都应该是suspend,对于所有Model层公开出来的方法都应该用suspend来修饰。
  2. Repository(即Data层)的每个方法的实现都要指定明确的Dispatcher,最好接收一个Dispatcher作为构造参数,以方便进行注入或者测试。
  3. ViewModel要把所有对Model的方法调用包裹在协程作用域viewModelScope中。

层与层之间的交互要定义接口

比如Model层提供给ViewModel的能力要定义为接口,然后在Data层中去实现这些接口。接口的最大好处是方便替换具体的实现,比如换个实现方式时,或者Mock测试时都能很方便的进行替换,甚至还可以使用动态代理在运行时进行替换。

对外部的依赖要作为构造器参数传入

作为构造参数传入外部依赖,而不是在内部直接创建,这样做的好处在于方便替换实例,无论是日后更换一种实现实例,还是Mock测试,都可以在不修改类本身的情况下进行实例替换。甚至可以使用依赖注入框架(如Hilt)做到让具体使用的实例可配置化。

Compose与ViewModel的交互原则

这部分我们具体谈一谈Compose与ViewModel在交互时的一些原则和建议。ViewModel暴露UiState给Compose展示,Compose则把事件(Event)给ViewModel去处理(可以是MVI式的把事件封装成Intent塞给ViewModel,也可以直接调用ViewModel的接口)。

图5. Compose与ViewModel的UDF

比如说一个典型的登录场景,其Composable和ViewModel应该像这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyViewModel : ViewModel() {
    private val _uiState = mutableStateOf<UiState>(UiState())
    val uiState: State<UiState>
        get() = _uiState
    // ...
}

data class UiState(
    loading: Boolean = false,
    signedIn: Boolean = false,
    error: String = ""
)

@Composable
fun MyComposable(viewModel: MyViewModel) {
    val uiState = viewModel.uiState.collectAsStateWithLifecycle()
    if (uiState.loading) {
        // show loading
    } else if (uiState.signedIn) {
        // show signed in status
    } else {
        // show uiState.error
    }
}

一定要定义专用的UiState数据对象

从ViewModel给UI的数据要封装成一个不可变的数据对象(data class)作为UiState,即使其数据与从Model处获得的数据没有变化,也应该定义并做基本转换。

原因就是让UI层和Model不会相互影响,假如直接把Model的数据传给UI,那假如以后Model层的数据有变动,这就会影响到UI。架构分层的目的就是要封装和隔离,每一层都应该定义自己的输出数据类型,把依赖和耦合降到最低。

当数据特别多时,分成多个UiState对象

如果页面较复杂,需要的字段特别多,这时应该把页面分成几个区域,同时UiState也应该分成几个不同的对象,而不是大一统的装在一个对象里面。

原因在于Compose会把从ViewModel处获得的UiState装在一个状态里面,从前面的文章中我们知道,状态是用于触发重组的,状态发生变化就会触发重组。因为数据多半部分变化的,甚至有些业务逻辑总是某几个字段在变化。因为字段都放在了一个对象中,那么即使只有一个字段变了,对于Compose来说,也是状态变化了,就要进行重组。

把字段按其变化的性质进行拆分,用几个UiState来表示,这样能把变化降到最低,只让真正有变化的UiState触发重组进而刷新UI。

Compose中要尽可能拆解为无状态函数

这里的意思是说我们应该把从ViewModel处获得的UiState拆解开来,变成具体的参数用无状态函数去展示,而不应该全都把UiState当成参数传给每一个composables。比如说对于一个新闻页面,对于标题元素就应该只接受两个String作为参数,而不应该把整个NewsUiState作为参数:

1
2
3
4
5
6
7
8
9
@Composable
fun Header(title: String, subtitle: String) {
    // 只有当title和subtitle发生变化时才会重组
}

@Composable
fun Header(news: NewsUiState) {
    // 只要状态变了就会重组,哪怕有关的title和subtitle没有变化
}

这样做的目的也是为了尽可能减少重组。

ViewModel塞给UI的数据要能够直接展示

UI层负责数据展示,不应该有过多的逻辑,特别是不能有涉及非UI直接相关的逻辑。ViewModel存在的意义就是为了把非UI相关的业务逻辑全从UI中拿走,把UI做的尽可能薄一些,这里薄的意思是逻辑要少。背后的核心原因在于方便测试和移植,众所周知UI是与每个平台强相关的,每个平台的UI构建方式都不一样,并且UI是极难做单元测试的,依赖太多很难Mock。

UI层很薄,仅是数据的展示,逻辑都放在ViewModel中,但ViewModel依赖很少,没有对平台和依赖,它的依赖对象都是可以Mock的,那么ViewModel就很容易做测试,只要测试保证ViewModel没什么问题,那么就基本上可以认为UI也不会有问题,毕竟光做展示一般不会出问题。

这里的最重要的一点就是要保证ViewModel递给UI的数据要是经过逻辑处理后的,可以直接展示的数据。举个粟子,比如说展示时间间隔的字段,类似于『100 ms』,『10 seconds』,『2 mins 10ms』或者『1 hour 10 mins』 这种,那么就不可以直接把一个Int或者Long传给UI,让UI去换算,而是让ViewModel做换算,把结果String传给UI,UI用一个Text直接显示String。这样一来就可测试了,我们Mock几个不同的字段值给ViewModel,再检查它吐出来的UiState中的时长String字段是否符合预期,就能保证这段逻辑没有问题。对于UI可以不用测试了,一个Text显示一个String出错的可能性不大,可以忽略了。

总结

架构最难的地方在于它是形而上学(Metaphysics)的,不会像算法或者设计模式那样有非常明确的和具体的实施步骤,仅是有一些抽象的指导原则。在实际的项目中就要从实际的业务场景出发,使用可用的技术工具,把指导原则进行落地,要把『神似』而非『形似』作为目标。切忌生搬硬套网上一些所谓的应用架构框架,假如真的有通用的架构框架,那谷歌为啥不写在SDK里面?不同的业务,不同的规模,不同的技术栈,不同的版本策略都会影响架构的方式。比如像第一部分提到的四大原则,难道只有用MVVM或者MVI才能做到么?即使用了MVVM,你在ViewModel直接把未处理的数据丢给UI,UI中仍有大量的处理数据的if-else,ViewModel把数据改了后两头更新,这明显不符合架构原则,最后仍是维护一坨巨大的shi山难解的Bug满天飞。所以对于网上的各种架构框架看看就好,一定不能生搬硬套。

可以通过谷歌提供的一些非常好的案例来学习架构,仔细研读这些案例的源码,以深刻理解架构指导原则的内在涵义。

References

Comments