今年七月底,Google
正式发布了 Jetpack Compose
的 1.0
稳定版本,这说明Google
认为Compose
已经可以用于生产环境了。相信Compose
的广泛应用就在不远的将来,现在应该是学习Compose
的一个比较好的时机
在了解了Compose
的基本知识与原理之后,通过一个完整的项目继续学习Compose
应该是一个比较好的方式。本文主要基于Compose
,MVI
架构,单Activity
架构等,快速实现一个wanAndroid
客户端,如果对您有所帮助可以点个Star
: wanAndroid-compose
首先看下效果图
------------------------------------------------------------ | ------------------------------------------------------------ |
各个页面的具体实现可以查看源码,这里主要介绍一些主要的实现与原理
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
早在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
架构下的这两个问题
在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
中获取生命周期,我们需要先了解下副作用
用一句话概括副作用:一个函数的执行过程中,除了返回函数值之外,对调用方还会带来其他附加影响,例如修改全局变量或修改参数等。
副作用必须在合适的时机执行,我们首先需要明确一下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 {
}
}
}
如上所示,每当进入页面或返回该页面时,我们就可以刷新页面登录状态了
相信使用过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
https://github.com/shenzhen2017/wanandroid-compose
开源不易,如果项目对你有所帮助,欢迎点赞,Star
,收藏~
https://github.com/manqianzhuang/HamApp
https://github.com/linxiangcheer/PlayAndroid
从零到一写一个完整的 Compose 版本的天气