作者:程序员江同学
前言
今年七月底,Google
正式发布了 Jetpack Compose
的 1.0
稳定版本,这说明Google
认为Compose
已经可以用于生产环境了。相信Compose
的广泛应用就在不远的将来,现在应该是学习Compose
的一个比较好的时机
在了解了Compose
的基本知识与原理之后,通过一个完整的项目继续学习Compose
应该是一个比较好的方式。 本文主要基于Compose
,MVI
架构,单Activity
架构等,快速实现一个wanAndroid
客户端,如果对您有所帮助可以点个Star
: wanAndroid-compose
效果图
首先看下效果图
主要实现介绍
各个页面的具体实现可以查看源码,这里主要介绍一些主要的实现与原理
使用MVI
架构
MVI
与 MVVM
很相似,其借鉴了前端框架的思想,更加强调数据的单向流动和唯一数据源,架构图如下所示
其主要分为以下几部分
Model
: 与MVVM
中的Model
不同的是,MVI
的Model
主要指UI
状态(State
)。例如页面加载状态、控件位置等都是一种UI
状态View
: 与其他MVX
中的View
一致,可能是一个Activity
或者任意UI
承载单元。MVI
中的View
通过订阅Model
的变化实现界面刷新Intent
: 此Intent
不是Activity
的Intent
,用户的任何操作都被包装成Intent
后发送给Model
层进行数据请求
例如登录页面的Model
与Intent
定义如下
/**
* 页面所有状态
/
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()
}
如上所示
- 通过
ViewState
定义页面所有状态 ViewEvent
定义一次性事件如Toast
,页面关闭事件等- 通过
ViewAction
定义所有用户操作
MVI
架构与MVVM
架构的主要区别在于:
MVVM
并没有约束View
层与ViewModel
的交互方式,具体来说就是View
层可以随意调用ViewModel
中的方法,而MVI
架构下ViewModel
的实现对View
层屏蔽,只能通过发送Intent
来驱动事件。MVVM
的ViewModle
中分散定义了多个State
,MVI
使用ViewState
对State
集中管理,只需要订阅一个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
来说,因为Activity
与Compose
是通过AndroidComposeView
来中转的,Activity
越多,就需要创建出越多的AndroidComposeView
,对性能有一定影响
而使用单Activity
架构,所有变换页面跳转都在Compose
内部完成,可能也是出于这个原因,目前Google
的示例项目都是基于单Activity
+Navigation
+多Compose
架构的
但是使用单Activity
架构也需要解决一些问题
- 所有的
viewModel
都在一个Activity
的ViewModelStoreOwner
中,那么当一个页面销毁了,此页面用过的viewModel
应该什么时候销毁呢? - 有时候页面需要监听自己这个页面的
onResume
,onPause
等生命周期,单Activity
架构下如何监听生命周期呢?
我们下面就一起来看下如何解决单Activity
架构下的这两个问题
页面ViewModel
何时销毁?
在Compose
中一般可以通过以下两种方式获取ViewModel
//方式1
@Composable
fun LoginPage(
loginViewModel: LoginViewModel = viewModel()
) {
//...
}
//方式2
@Composable
fun LoginPage(
loginViewModel: LoginViewModel = hiltViewModel()
) {
//...
}
如上所示:
- 方式1将返回一个与
ViewModelStoreOwner
(一般是Activity
或Fragment
)绑定的ViewModel
,如果不存在则创建,已存在则直接返回。很明显通过这种方式创建的ViewModel
的生命周期将与Activity
一致,在单Activity
架构中将一直存在,不会释放。 - 方式2通过
Hilt
实现,可以在Composable
中获取NavGraph Scope
或Destination Scope
的ViewModel
,并自动依赖Hilt
构建。Destination Scope
的ViewModel
会跟随BackStack
的弹出自动Clear
,避免泄露。
总得来说,通过hiltViewModel
与Navigation
配合,是一个更好的选择
Compose
如何获取生命周期?
为了在Compose
中获取生命周期,我们需要先了解下副作用
用一句话概括副作用:一个函数的执行过程中,除了返回函数值之外,对调用方还会带来其他附加影响,例如修改全局变量或修改参数等。
副作用必须在合适的时机执行,我们首先需要明确一下Composable
的生命周期:
onActive(or onEnter)
:当Composable
首次进入组件树时onCommit(or onUpdate)
:UI
随着recomposition
发生更新时onDispose(or onLeave)
:当Composable
从组件树移除时
了解了Compose
的生命周期后,我们可以发现,如果我们在onActive
时监听Activity
的生命周期,在onDispose
时取消监听,不就可以实现在Compose
中获取生命周期了吗?
DisposableEffect
可以帮助我们实现这个需求,DisposableEffect
在其监听的Key
发生变化,或onDispose
时会执行
我们还可以通过添加参数,让其仅在onActive
与onDispose
时执行:例如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
加载分页数据,并显示到页面A
的LazyColumn
上,向下滑动LazyColumn
,然后navigation.navigate
跳转到页面B
,接着再navigatUp
回到页面A
,页面A
的LazyColumn
又回到了列表顶部
但是我们可以看到,LazyListState
其实是通过rememberLazyListState
做了持久化保存的,如下图所示
既然做了持久化保存,那为什么返回时的位置还有问题呢?其实纯粹使用 Paging
+ LazyColumn
,当页面切换时,会记录当前页面位置,但如果通过item
加上Header
或Footer
就不行了
这是因为rememberLazyListState
会在列表中至少有一项时restore
滚动位置,同时Paging
是通过Flow
获取数据的,当返回到页面重组时并不能马上获取到Paging
数据,第一帧时Paging
的itemCount
为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
已经足够,但是对于有Header
或Footer
的Paging
页面,需要一些特殊处理
关于LazyColumn
滚动丢失的问题,更详细的讨论可参考:Scroll position of LazyColumn built with collectAsLazyPagingItems is lost when using Navigation