Compose + MVI + Navigation 快速实现 wanAndroid 客户端

17,603 阅读7分钟

前言

今年七月底,Google 正式发布了 Jetpack Compose1.0 稳定版本,这说明Google认为Compose已经可以用于生产环境了。相信Compose的广泛应用就在不远的将来,现在应该是学习Compose的一个比较好的时机
在了解了Compose的基本知识与原理之后,通过一个完整的项目继续学习Compose应该是一个比较好的方式。 本文主要基于ComposeMVI架构,单Activity架构等,快速实现一个wanAndroid客户端,如果对您有所帮助可以点个Star: wanAndroid-compose

效果图

首先看下效果图

请添加图片描述在这里插入图片描述
请添加图片描述在这里插入图片描述
------------------------------------------------------------------------------------------------------------------------
请添加图片描述请添加图片描述

主要实现介绍

各个页面的具体实现可以查看源码,这里主要介绍一些主要的实现与原理

使用MVI架构

MVIMVVM 很相似,其借鉴了前端框架的思想,更加强调数据的单向流动和唯一数据源,架构图如下所示

其主要分为以下几部分

  1. Model: 与MVVM中的Model不同的是,MVIModel主要指UI状态(State)。例如页面加载状态、控件位置等都是一种UI状态
  2. View: 与其他MVX中的View一致,可能是一个Activity或者任意UI承载单元。MVI中的View通过订阅Model的变化实现界面刷新
  3. Intent: 此Intent不是ActivityIntent,用户的任何操作都被包装成Intent后发送给Model层进行数据请求

例如登录页面的ModelIntent定义如下

/**
* 页面所有状态
/
data class LoginViewState(
    val account: String = "",
    val password: String = "",
    val isLogged: Boolean = false
)

/**
 * 一次性事件
 */
sealed class LoginViewEvent {
    object PopBack : LoginViewEvent()
    data class ErrorMessage(val message: String) : LoginViewEvent()
}

/**
* 页面Intent,即用户的操作
/
sealed class LoginViewAction {
    object Login : LoginViewAction()
    object ClearAccount : LoginViewAction()
    object ClearPassword : LoginViewAction()
    data class UpdateAccount(val account: String) : LoginViewAction()
    data class UpdatePassword(val password: String) : LoginViewAction()
}

如上所示

  1. 通过ViewState定义页面所有状态
  2. ViewEvent定义一次性事件如Toast,页面关闭事件等
  3. 通过ViewAction定义所有用户操作

MVI架构与MVVM架构的主要区别在于:

  1. MVVM并没有约束View层与ViewModel的交互方式,具体来说就是View层可以随意调用ViewModel中的方法,而MVI架构下ViewModel的实现对View层屏蔽,只能通过发送Intent来驱动事件。
  2. MVVMViewModle 中分散定义了多个 StateMVI 使用 ViewStateState 集中管理,只需要订阅一个 ViewState 便可获取页面的所有状态,相对 MVVM 减少了不少模板代码

Compose 的声明式UI思想来自 React,理论上同样来自 Redux 思想的 MVI 应该是 Compose 的最佳伴侣
但是MVI也只是在MVVM的基础上做了一定的改良,MVVM 也可以很好地配合 Compose 使用,各位可根据自己的需要选择合适的架构

关于Compose的架构选择可参考:Jetpack Compose 架构如何选? MVP, MVVM, MVI

Activity架构

早在View时代,就有不少推荐单Activity+多Fragment架构的文章,Google也推出了Jetpack Navigation库来支持这种单Activity架构
对于Compose来说,因为ActivityCompose是通过AndroidComposeView来中转的,Activity越多,就需要创建出越多的AndroidComposeView,对性能有一定影响
而使用单Activity架构,所有变换页面跳转都在Compose内部完成,可能也是出于这个原因,目前Google的示例项目都是基于单Activity+Navigation+多Compose架构的

但是使用单Activity架构也需要解决一些问题

  1. 所有的viewModel都在一个ActivityViewModelStoreOwner中,那么当一个页面销毁了,此页面用过的viewModel应该什么时候销毁呢?
  2. 有时候页面需要监听自己这个页面的onResumeonPause等生命周期,单Activity架构下如何监听生命周期呢?

我们下面就一起来看下如何解决单Activity架构下的这两个问题

页面ViewModel何时销毁?

Compose中一般可以通过以下两种方式获取ViewModel

//方式1   
@Composable
fun LoginPage(
    loginViewModel: LoginViewModel = viewModel()
) {
	//...
}

//方式2   
@Composable
fun LoginPage(
    loginViewModel: LoginViewModel = hiltViewModel()
) {
	//...
}

如上所示:

  1. 方式1将返回一个与ViewModelStoreOwner(一般是ActivityFragment)绑定的ViewModel,如果不存在则创建,已存在则直接返回。很明显通过这种方式创建的ViewModel的生命周期将与Activity一致,在单Activity架构中将一直存在,不会释放。
  2. 方式2通过Hilt实现,可以在Composable中获取NavGraph ScopeDestination ScopeViewModel,并自动依赖 Hilt 构建。Destination ScopeViewModel 会跟随 BackStack 的弹出自动 Clear ,避免泄露。

总得来说,通过hiltViewModelNavigation配合,是一个更好的选择

Compose如何获取生命周期?

为了在Compose中获取生命周期,我们需要先了解下副作用
用一句话概括副作用:一个函数的执行过程中,除了返回函数值之外,对调用方还会带来其他附加影响,例如修改全局变量或修改参数等。

副作用必须在合适的时机执行,我们首先需要明确一下Composable的生命周期:

  1. onActive(or onEnter):当Composable首次进入组件树时
  2. onCommit(or onUpdate)UI随着recomposition发生更新时
  3. onDispose(or onLeave):当Composable从组件树移除时

了解了Compose的生命周期后,我们可以发现,如果我们在onActive时监听Activity的生命周期,在onDispose时取消监听,不就可以实现在Compose中获取生命周期了吗?
DisposableEffect可以帮助我们实现这个需求,DisposableEffect在其监听的Key发生变化,或onDispose时会执行
我们还可以通过添加参数,让其仅在onActiveonDispose时执行:例如DisposableEffect(true)DisposableEffect(Unit)

通过以下方式,就可以实现在Compose中监听页面生命周期

@Composable
fun LoginPage(
    loginViewModel: LoginViewModel = hiltViewModel()
) {
    val lifecycleOwner = LocalLifecycleOwner.current
    DisposableEffect(key1 = Unit) {
        val observer = object : LifecycleObserver {
            @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
            fun onResume() {
                viewModel.dispatch(Action.Resume)
            }

            @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
            fun onPause() {
                viewModel.dispatch(Action.Pause)
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }

    }
}

当然有时也不需要这么复杂,比如我们需要在进入或返回ProfilePage页面时刷新登录状态,并根据登录状态确认页面UI,就可以通过以下方式实现

@Composable
fun ProfilePage(
    navCtrl: NavHostController,
    scaffoldState: ScaffoldState,
    viewModel: ProfileViewModel = hiltViewModel()
) {
    //...

    DisposableEffect(Unit) {
        Log.i("debug", "onStart")
        viewModel.dispatch(ProfileViewAction.OnStart)
        onDispose {
        }
    }
}    

如上所示,每当进入页面或返回该页面时,我们就可以刷新页面登录状态了

Compose如何保存LazyColumn列表状态

相信使用过LazyColumn的同学都碰到过下面的问题

使用Paging3加载分页数据,并显示到页面ALazyColumn上,向下滑动LazyColumn,然后navigation.navigate跳转到页面B,接着再navigatUp回到页面A,页面ALazyColumn又回到了列表顶部

但是我们可以看到,LazyListState其实是通过rememberLazyListState做了持久化保存的,如下图所示

既然做了持久化保存,那为什么返回时的位置还有问题呢?其实纯粹使用 Paging + LazyColumn,当页面切换时,会记录当前页面位置,但如果通过item加上HeaderFooter就不行了
这是因为rememberLazyListState会在列表中至少有一项时restore滚动位置,同时Paging是通过Flow获取数据的,当返回到页面重组时并不能马上获取到Paging数据,第一帧时PagingitemCount为0
但同时因为LazyColumn中已经有了一个Header,这时便会还原保存的位置,但因为这时Paging中的数据还为空,不能滚动到正确的位置,于是便又滚动到顶部了
而当LazyColumn中没有Header时,列表中至少有一项时便是Paging数据成功填充的时候,这个时候还原的位置就是对的,所以没有问题

既然原因在于LazyListState没有在正确的时机被还原,那我们将LazyListSate保存在ViewModel中,并且在Paging中有数据时再还原listState,如下所示:

@HiltViewModel
class SquareViewModel @Inject constructor(
    private var service: HttpService,
) : ViewModel() {
    private val pager by lazy { simplePager { service.getSquareData(it) }.cachedIn(viewModelScope) }
    val listState: LazyListState = LazyListState()
}

@Composable
fun SquarePage(
    navCtrl: NavHostController,
    scaffoldState: ScaffoldState,
    viewModel: SquareViewModel = hiltViewModel()
) {
    val squareData = viewStates.pagingData.collectAsLazyPagingItems()
    // 当`Paging`有数据时,返回`ViewModel`中的`listState`
    val listState = if (squareData.itemCount > 0) viewStates.listState else LazyListState()

    RefreshList(squareData, listState = listState) {
        itemsIndexed(squareData) { _, item ->
           //...
        }
    }
}

总得来说,对于一般的页面,rememberLazyListState已经足够,但是对于有HeaderFooterPaging页面,需要一些特殊处理
关于LazyColumn滚动丢失的问题,更详细的讨论可参考:Scroll position of LazyColumn built with collectAsLazyPagingItems is lost when using Navigation

总结

项目地址

github.com/shenzhen201…
开源不易,如果项目对你有所帮助,欢迎点赞,Star,收藏~

参考资料

github.com/manqianzhua…
github.com/linxiangche…
从零到一写一个完整的 Compose 版本的天气