Jetpack Compose 中的架构思想

Posted 川峰

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Jetpack Compose 中的架构思想相关的知识,希望对你有一定的参考价值。


如果应用打算使用 Jetpack Compose 来开发,那么就可以跟以前的MVC、MVP、MVVM等乱七八糟的架构全部说拜拜,这些名词也将在android开发当中永远地成为历史。因为 Jetpack Compose 的架构思想非常简单,只有UI层数据层两层,即上图所示(其中 Domain Layer 是可选的层)。它的核心思想是单向数据流,以数据模型驱动界面,遵循关注点分离的原则。

上面的架构图同时也为我们重新定义了到底什么是 UI:

State和逻辑的分类

  • UI element state: UI 元素提升的状态(如ScaffoldState)
  • Screen or UI state: 需要在屏幕上显示的东西(如CartUiState)
  • UI 行为逻辑: 如何显示状态变化(如导航逻辑或显示snackbar)
  • 业务逻辑: 如何处理状态变化(如发起支付或存储用户preferences)

StateHolder

在前面的架构图中可以看到,UI 层其实细分又包含了两层:UI 元素状态容器(StateHolder)。其中 UI 元素 就是我们常见的各种 Composable 组件,而 状态容器 则是承载这些 UI 元素 所需要的各种状态。同时状态容器也会接受从 UI 元素 中产生的各种事件,因为只要有UI交互就一定会产生事件。StateHolder 的存在避免了界面同时成为界面和状态的管理者。有一句话我觉得可以很好的表达它的职责:“吸收事件,生成状态”。

可以作为 StateHolder 状态容器的一般有两种,一种是使用持有 UI 状态的普通的类来管理,另一种是使用 ViewModel 来管理。

下面是一个使用普通的类来管理 UI 状态的示例:

class MyAppState(
    val scaffoldState: ScaffoldState,
    val navController: NavController,
    private val resources: Resources,
    ...
) 
   val bottomBarTabs = /* State */
   val shouldShowBottomBar: Boolean // 决定什么什么时候显示BottomBar的逻辑代码
        get() = /* ... */
   fun navigateToBottomBarRoute(route: String) /* ... */ // 导航逻辑,是UI逻辑的一种类型
   fun showSnackBar(message: String) /* ... */ // 显示SnackBar的逻辑


@Composable
fun rememberMyAppState(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    navController: NavController = rememberNavController(),
    resources: Resources = LocalContext.current.resources,
    ...
) = remember(scaffoldState, navController, resources, ...) 
    MyAppState(scaffoldState, navController, resources, /* ... */)

然后在 Composable 中使用自定义的 StateHolder 来读取各种状态和执行UI跳转逻辑等。

@Composable
fun MyApp() 
    MyApplicationTheme 
        val myAppState = rememberMyAppState()
        Scaffold(
            scaffoldState = myAppState.scaffoldState,
            bottomBar = 
                if (myAppState.shouldShowBottomBar) 
                    BottomBar(
                        tabs = myAppState.bottomBarTabs,
                        navigateToRoute = 
                            myAppState.navigateToBottomBarRoute(it)
                        
                    )
                
            
        ) 
            NavHost(navController = myAppState.navController,  "WelcomeScreen") /* ... */
        
    

注意:凡是要在 Composable 中使用的状态一定要使用 remember ,因为 Composable 是会被重复执行的(重组),所以对于自定义的状态容器类对象也要使用 remember 来创建,最好是提供一个配套的 remember 函数。

官方推荐的可以作为 StateHolder 的正牌状态容器其实是 ViewModel, 因为普通的状态管理类无法做到像 ViewModel 那样在横竖屏切换等配置发生改变的场景时自动恢复(但依然可通过rememberSavable来实现同样效果)。

从某种意义上讲, ViewModel 只是一种特殊的 StateHolder ,但因为它保存在 ViewModelStore 中,所以有以下特点:

  • 存活范围大:可以脱离 Composition 存在,被所有的 Composable 共享访问。
  • 存活时间长:不会因为横竖屏切换后进程被杀死等情况丢失状态。

因此 ViewModel 适合管理应用级别或者屏幕级别的全局状态,各个 Composable 可以通过 viewModel() 获取 ViewModel 单例达到 “全局共享” 的效果,而且 ViewModel 更倾向于管理那些非 UI 的业务状态,业务状态中的数据往往需要脱离 UI 长期保存。

例如:

data class ExampleUiState(
    val dataToDisplayOnScreen: List<Example> = emptyList(),
    val userMessages: List<Message> = emptyList(),
    val loading: Boolean = false
)

class ExampleViewModel(
    private val repository: UserPreferRepository,
    private val savedStateHandle: SavedStateHandle
): ViewModel() 
    var uiState by mutableStateOf(ExampleUiState())
        private set // 私有化set操作,只有当前ViewModel内部可以修改,对外部来说不可修改
    fun someBusinessLogic() 
        // 执行业务逻辑
        // savedStateHandle.set("key", uiState)
    


@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) 
    val uiState = viewModel.uiState
    // ... 使用 uiState 
    Button(onClick =  viewModel.someBusinessLogic() ) 
        Text("Do Something")
    

在上面代码中,ExampleUiState 中包含了 userMessages 这样的领域层数据,以及 loading 这样的代表数据加载状态的数据,这些都与 UI 无关,适合用 ViewModel 进行管理。此外, ViewModel 中可以利用 SavedStateHandle 实现对 UiState 的持久化保存。

注意:viewModel() 是一个 Composable 函数,专门用于在 Composable 中创建或获取对应类型的 ViewModel 对象实例,使用它需要单独添加依赖:androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1

viewModel() 会从最近的 ViewModelStore 中获取ViewModel 实例,这个 ViewModelStore 可能是一个 Activity,也可能是一个 Fragment。如果ViewModel 实例不存在,就会创建一个新的并存入 ViewModelStore 中。只要 ViewModelStore 不销毁,其内部的ViewModel 将一直存活。例如一个 Activity 中的 Composable 通过 viewModel()创建的 ViewModel 被当前的 Activity 持有。在 Activity销毁之前,ViewModel 将一直存在,每次调用viewModel()都会返回同一个实例,所以它可以不用 remember 进行缓存。

UI 元素接收到用户的输入事件之后,会向 ViewModel 通知,ViewModel 在经过业务逻辑的处理之后会更新状态,而被更新的状态则会向 UI 元素反馈(注意这种反馈是UI 元素主动感知的,而非被动,得益于mutableStateOf)。在整个过程当中,事件的处理形成一种单向的流

ViewModel 作为唯一数据来源的使用总结:

  • 将状态保留在组合之外
  • 访问业务逻辑以及在屏幕上显示的东西 (UI state)
  • 依赖于层次结构的其他层(例如数据和业务层)
  • 推荐用于屏幕级的 Composable 组合

ViewModel 和 普通状态管理类 该如何选择:

  • ViewModel 优于普通状态管理类的地方:
    • 配置更改后操作仍然有效,提供全局唯一单例
    • 可以方便的与Jetpack相关库集成
    • 缓存在导航路由堆栈中,并在目的地弹出时清除
  • 如果这些好处对你的业务场景不适用,则应该更倾向于使用简单的普通状态管理类
  • 普通的状态管理类可以依赖于 ViewModel
  • 当State需要多实例的场景时,建议使用普通的状态管理类

这里值得一提的是,在一些复杂的场景中,ViewModel 和 普通状态管理类 是可以并存的 ,二者并不冲突。Composable 或者 StateHolder 可以依赖 ViewModel 管理 UI 无关的状态及对应的业务逻辑。借助 ViewModel,这些状态可以跨越 Composable 甚至 Activity 的生命周期长期存在。而 ViewModel 依赖于更底层的领域层或者数据层完成相关业务,因为处于底层的业务服务范围往往更广,存活时间也更长

Stateful 组件与 Stateless 组件

只依赖参数的 Composable 组件被称为 Stateless 组件,例如以下代码:

@Composable
fun Greeting(name: String) 
	Text(text= "Hello $name")

Greeting除了依赖参数以外,不依赖任何其他状态。

相对的,在 Composable 内部持有或者访问某些状态的组件是 Stateful 组件。

Stateless 的 Composable 的重组只能来自其上层调用的 Composable ,而 Stateful 的 Composable 的重组来自其依赖的状态的变化。 Stateless 的 Composable 是一个 “纯函数”,也就是完全没有副作用的函数,参数是其变化的唯一来源,只要参数不变UI就不会变化。因此 Compose 编译器对其进行了优化,当Stateless 的参数没有变化时会对其跳过重组,重组范围局限在Stateless的外部。

如果有了解过 Flutter 开发可能更加容易理解这个概念,在 Flutter 中,直接提供了两种显示的定义 Widget 组件的方式:即 StatefulWidget 和 StatelessWidget。

State Hoisting (状态提升)

简单的来说,状态提升就是将 Stateful 的组件改造成 Stateless 的组件。状态提升可以使 Composable 组件提高复用性,同时由于 Stateless 组件不耦合任何业务逻辑,功能更加纯粹,因此也提供了其可测试性。状态提升使 Composable 更加容易遵循单一数据源的原则,降低出现bug的风险。

状态提升通常的做法就是将内部状态移除,然后通过参数来传入内部需要UI显示的状态以及需要处理的回调事件,即将状态提升到调用者那一层,由调用者负责为子级组件提供状态和处理事件的方法,Stateless 是相对于调用者而言的。

Jetpack Compose 中的常规状态提升模式是将状态变量替换为两个参数:

  • value: T:要显示的当前值
  • onValueChange: (T) -> Unit:请求更改值的事件,其中 T 是建议的新值

不过,事件处理的回调并不局限于 onValueChange。你可以使用 lambda 定义任何更加具体的事件。

例如,以下代码中 CartItem 组件将其依赖状态和事件处理向外部公开,这样 CartItem 组件就变成了一个 Stateless 组件:

然后在调用 CartItem 的地方为其提供所需状态和处理事件的方法:

注意这里 Cart 组件并没有直接给出事件处理的具体实现,而是将其委托给 CartViewModel , 因为具体的事件处理属于业务逻辑的范畴。

注意:使用viewModel() 方法的Composable无法进行预览,因此通过状态提升将需要预览的部分提取为 Stateless 组件更加有助于调试。

另外前面提到 ViewModel 作为正牌的状态容器,它可以向 UI 提供状态,但是有一点需要注意的是它不能持有由 Composable 创建的状态,也就是说 ViewModel 的内部可以创建并暴露一些 UI stateUI elements,而 UI elements 中的创建的 state 不能传入 ViewModel 中。因为 ViewModel 不属于组合树的一部分,它的生命周期比组合更长,因此它不应该接受组合作用域内的状态(假如你这样做了,那么要谨慎了,因为这可能导致内存泄漏)。

相应的,调用者如果是通过 ViewModelStateless 组件提供状态必须是在 ViewModel 内部创建且必须保证这些创建的 State 必须是可追踪/可观察的(通过mutableStateOf 实现):

如果 ViewModel 内部创建的 State 不是通过mutableStateOf 实现的(例如LiveDataFlow或者RxJava等,这可能是之前旧项目中留下来的),那么此时调用者应该使用 Compose 中提供的相应的转换方法来转换成可被 Composable 观察的状态。例如针对 Flow可使用 collectAsState 函数转换:

(其他库的转换可参考之前在Jetpack Compose中的副作用中提到的关于第三方库适配的部分。)

总而言之, Composable 的状态提升是不能无限提升的,最高可提升至的层次应该是某个路由导航目的地的屏幕级 Composable 中,因为再往上没有了,也就是到达了根路由在 NavHost 中的配置。

但并不是 UI elements 中的每一个状态和事件处理方法都需要提升至最顶层的屏幕级可组合项中的,那么状态提升到底要提升到哪一个层次较为合适呢?关于这一点,官方也给出了两条建议:

  • 状态应该提升至所有消费该状态的父组件中的最低公共父组件中
  • Composable 向调用者公开的参数应该仅传递它需要的参数,减少不必要的多余参数

Domain Layer (领域层)

开头提到过,Domain Layer 是一个可选的层,也就说它是可有可无的一层,至于其是否真的有存在的必要性,只能说仁者见仁智者见智了。但是,如果应用中存在了 Domain Layer 这一层,那么至少需要保证的是该层应该只包含纯业务逻辑,而不应该包含UI状态。

其中,Domain Layer 中的 UseCase 可以横向依赖该层的其他 UseCase ,也可以向下依赖 Data Layer 层中的不同的 Repository 。但是它不能向上依赖 UI Layer(即它不能包含 UI状态)层,相反的, UI Layer 应该依赖 Domain Layer 中的 UseCaseUseCase 可以选择向 UI 层暴露挂起函数、Flow或者是回调方法等。

UseCase的创建

关于 UseCase 的实现方式,官方给出的一点建议是使 UseCase 可调用(重载invoke操作符),这样的好处是可以让别人明显的知道你在调用领域层的类。

UseCase的线程安全

关于线程方面,官方给出了两条建议:

  1. 确保主线程安全, 如果一个 UseCase 中执行耗时过长的任务,那么应该让它脱离主线程,把它移动到子线程中执行。
  2. 如果一个任务执行的结果可以被缓存,那么可以将该任务移动到 Data Layer 中。

封装和提取可重用的业务逻辑

最常见的场景可能是在 UI 层调用的一些工具类:

注意 UI 层包括 UI elementsSateHolder ,所以这里的提到的 Util Classes 多半存在于 ViewModel 这样的 SateHolder 当中。

此时,可以将这些 Util Classes 提取并下沉到领域层作为 UseCase 实现,以达到复用性目的。

合并来自多个Repository的数据

如果 UI 层的 ViewModel 中需要同时访问多个来自数据层的 Repository,然后将它们获得的结果进行合并后为 UI elements 提供显示状态。

此时就可以将访问多个 Repository 合并数据的逻辑下沉到领域层作为 UseCase 来实现。

同时,需要注意在 UseCase 的代码实现时不应该阻塞调用者的线程(因为调用者来自UI层),可以选择将invoke函数声明为挂起函数,将请求数据合并的逻辑移动到子线程调度器的上下文中执行,并将协程调度器作为公开参数以便调用者可以自行决定在哪个线程中执行。这种做法也可以同时提高可测试性。

Domain Layer 总结:

  • 减少 UI Layer 的复杂度
  • 避免重复性工作,提高可复用性
  • 提高可测试性

Data Layer (数据层)

数据层是应用中真正提供数据源的地方,它由 Repository 存储仓库组成,每个存储仓库可以与0个、1个或者多个数据源进行交互。其中数据源可以是网络、本地数据库、文件、DataStore甚至是内存数据等。

Repository 存储仓库的主要职责:

  1. 向应用其余层公开数据访问
  2. 集中处理对数据的更改
  3. 解决多个数据源之间的冲突
  4. 容纳业务逻辑

每一个 Repository 存储仓库应该对应一种数据类型,而每一个 Data Source 中通常应该只负责一种业务类型。例如,电影相关的和支付相关的数据处理应该分别创建一个 Repository 来管理。

存储仓库应该负责解决本地数据缓存和远程服务器数据之间存在的冲突:

请记住,应用中的其他所有层都不能与数据源直接进行交互,访问数据源的入口应当始终都是 Repository 类。Repository 类的一种常用模式是执行一次性调用操作,例如创建、读取、更新和删除。这些可通过 Kotlin 中的 suspend 函数来实现。

但是也可以通过公开数据流(例如使用Flow)来获得数据随时间变化的通知:

处理多个数据源

在一个Repository中处理多个数据源可能比较棘手,你需要选择一个可靠的来源并确保它始终处于一致的状态。

例如,现在有两个新闻数据源,其中LocalNewsDataSource 依赖一个本地 room 数据库,而RemoteNewsDataSource依赖于一个远程 API 客户端(例如Retrofit):

然后,向其他层提供新闻列表的 NewsRepository 同时包含上面两个数据源:

fectNews() 方法中,首先从远程数据源中获取,如果成功就会更新本地数据源,如果失败或更新过程中出现异常,会打印日志,或者在这里提醒用户。但是无论如何,最终返回的都是从本地数据源中查询的结果,因为它是我们可靠的来源。


这种情况非常简单,因为我们使用的是用户无法修改的数据。但是有些情况下可能会更加复杂一些,例如一个日历应用,如果有两个用户同时修改了其中的某个会议,这时就需要考虑的更多一些以确保用户的体验。

公开的数据应该确保不可变性

数据层公开的数据应该确保不可变性,任何类都不能对其进行篡改,篡改数据可能造成数据不一致问题。不可变性的另一个好处是,它可以被多线程安全地处理。实现不可变性的最好办法就是使用 Kotlin 的 data class

这里还需要考虑的一点是数据库或远程API返回的实体类模型可能并不是其他层所需要的模型,所以最好是单独创建一个实体模型,确保只提供其他层所需要的那些数据而不是所有数据:

这样不仅代码更简洁而且还能更好地隔离潜在的问题。

线程安全

同样地,调用数据源和存储仓库也不应该阻塞主线程,对于长时间的获取数据源的方法,存储仓库应该负责将其执行移至其他线程中:

异常处理

关于在请求数据源时可能发生的异常处理,一种方式是不处理,向外抛出异常,然后在 Domain LayerUI Layer 层中调用处通过常规的 try-catch 捕获异常进行处理:

或者如果是使用的 Kotlin Flow,可以使用 catch 运算符:

另外一种异常处理的方式是在数据层的内部处理,然后以一种更容易理解的方式,向外公开包含成功或者失败的结果数据,但不要忘记错误处理。

多个层级的 Repository

存储仓库可能依赖于多个数据源,在某些情况下,你可能希望有多个级别的存储仓库。

例如,用户存储仓库可能需要来自于日志记录存储仓库和注册存储仓库的数据:

同时,一个数据源可能被多个存储仓库共用。这种分层的设计只是一种推荐的做法,在实践中你可以有不同的实现。

Entities划分

应用中会涉及到许多的 Entity 实体类,根据所处的架构层次,可将 Entity 做如下分类:

  • Remote Entities:远程查询请求对应的实体类(Data Layer)
  • Database Entities:数据库查询对应的实体类(Data Layer)
  • Domain Entities:领域层使用的实体类
  • UiState: UI层使用的实体类


Compose 中的状态持久化与恢复

对于 Jetpack Compose 中的状态持久化,有两种方式:

  • ViewModel:它保存在 Activity/FragmentViewModelStore 中,在Activity因为横竖屏切换等配置变更导致页面销毁重建之后,仍然可以获取到之前的 ViewModel 实例。前面提到过,它实际上是一种全局共享状态,因此 Composable 可以借助 ViewModel 单例来实现状态的持久化。这里可持久化的状态比较适合于那些在 ViewModel 内部创建的公开给 ComposableUiState,而对于 Composable 内创建的状态不太适合通过 ViewModel 持久化保存(虽然也可以通过ViewModel中的SavedStateHandle来实现,可参考这里)。
  • rememberSaveable: 这个 API 是对 remember 的封装容器,可在 Bundle 中存储数据。 remember 缓存的状态可以跨越重组,但不能跨越Activity重建或进程重建。而 rememberSaveable 不仅能让状态在重组后保留下来,还能让状态在重新创建 activity 和系统发起的进程终止后继续留存。它可以像 Activity 的 onSaveInstanceState() 那样在进程被杀死时自动保存状态,同时像 onRestoreInstanceState() 一样随进程重建而自动恢复。

rememberSaveable 中的数据会以 Bundle 的形式通过 LocalSaveableStateRegistry 存储在内部的 Map 当中,并在进程或者 Activity 重建时根据 key 恢复到对应的 Composable 中,这个 key 就是 Composable 在编译期被确定的唯一标识。注意,在由系统发起的进程终止时,状态会保存,而用户手动退出应用时,状态才会被清空。

以下是使用rememberSaveable的一个简单示例:

@Composable
fun HelloScreen()  
    var name by rememberSaveable  mutableStateOf("")  // 在配置更改(如旋转屏幕)后保持状态 
    // var name by remember  mutableStateOf("")  // 这种方式不能在配置更改后保持状态 
    HelloContent(name = name, onNameChange =  name = it )



@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) 
    Column(modifier = Modifier.padding(16.dp)) 
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.titleSmall
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label =  Text("Name") 
        )
    

rememberSaveable 可以支持所有 Bundle支持的数据类型,如果要保存的内容无法被 Bundle 支持,例如对象类型,可以通过以下几种方式解决:

1. 向对象添加 @Parcelize 注解,例如,以下代码将 City 类变为一个 Parcelable 对象,由于 MutableState 本身也是一个 Parcelable 对象,因此可以直接保存到 rememberSaveable 中。

@Parcelize
data class City(val name: String, val country: String) : Parcelable

@Composable
fun CityScreen() 
    var selectedCity = rememberSaveable 
        mutableStateOf(City("Madrid", "Spain"))
    

使用@Parcelize注解需要在gradle中添加 kotlin-parcelize 插件: plugins id 'kotlin-parcelize' ,当一个类实现 Parcelable 接口且添加@Parcelize注解时,Kotlin 编译器会自动为其添加 Parcelable 的相关实现。

2. 自定义 Saver,如果某种原因导致 @Parcelize 不合适,比如定义在第三方库中的类,此时可以通过实现 Saver 接口来自定义保存和恢复数据的逻辑,然后在调用rememberSaveable时传入此 Saver 实例即可。

data class City(val name: String, val country: String)

object CitySaver: Saver<City, Bundle> 
    val key1 = "name"
    val key2 = "country"
    
    override fun restore(value: Bundle): City? 
        return value.getString(key1)?.let  name ->
            value.getString(key2)?.let  country ->
                City(name, country)
            
        
    

    override fun SaverScope.save(value: City): Bundle? 
        return Bundle().apply  
            putString(key1, value.name)
            putString(key2, value.country)
         
    

@Composable
fun CityScreen() 
    var selectedCity = rememberSaveable(stateSaver = CitySaver) 
        mutableStateOf(City("Madrid", "Spain"))
    

3. MapSaver,类似 Saver 接口,mapSaver使用更加简单,同样只需实现保存和恢复的逻辑即可。

data class City(val name: String, val country: String)

val CitySaver = run 
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save =  mapOf(nameKey to it.name, countryKey to it.country) ,
        restore =  City(it[nameKey] as String, it[countryKey] as String) 
    )


@Composable
fun CityScreen() 
    var selectedCity = rememberSaveable(stateSaver = CitySaver) 
        mutableStateOf(City("Madrid", "Spain"))
    

4. ListSaver,使用 listSaver 可以将其索引作为key,避免自己写key

data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save =  listOf(it.name, it.country) ,
    restore =  City(it[0] as String, it[1] as String) 
)

@Composable
fun CityScreen() 
    var selectedCity = rememberSaveable(stateSaver = CitySaver) 
        mutableStateOf(City("Madrid", "Spain"))
    

rememberSaveable 还可以接收一个 inputs 可变参数,其作用与 remember 接收 keys 的作用相同,即当输入参数发生更改时,缓存就会失效。下次函数重组时,rememberSaveable 会对 lambda 块重新执行计算。例如下面的示例中,rememberSaveable 会存储 userTypedQuery,直到 typedQuery 发生变化:

var typedQuery by remember  mutableStateOf("") 
var userTypedQuery by rememberSaveable(typedQuery, stateSaver = TextFieldValue.Saver) 
    mutableStateOf(
        TextFieldValue(text = typedQuery, selection = TextRange(typedQuery.length))
    )

如果状态只是需要跨越 ConfigurationChanged 存在,而不需要跨越进程恢复,那么可以在 AndroidManifest.xml

以上是关于Jetpack Compose 中的架构思想的主要内容,如果未能解决你的问题,请参考以下文章

Jetpack Compose 架构如何选?MVP MVVM 还是 MVI?

Jetpack Compose 架构比较:MVP & MVVM & MVI

jetpack compose 开发架构选择探讨

Android高级Jetpack架构组件+Jetpack compose强化实战

jetpack compose 开发架构选择探讨

Jetpack Compose+架构=优秀APP?