wanAndroid 客户端
前言
今年七月底,Google 正式发布了 Jetpack Compose 的 1.0 稳定版本,这说明Google认为Compose已经可以用于生产环境了。相信Compose的广泛应用就在不远的将来,现在应该是学习Compose的一个比较好的时机
在了解了Compose的基本知识与原理之后,通过一个完整的项目继续学习Compose应该是一个比较好的方式。本文主要基于Compose,MVI架构,单Activity架构等,快速实现一个wanAndroid客户端,如果对您有所帮助可以点个Star: wanAndroid-compose
效果图
首先看下效果图
![]() | ![]() |
|---|---|
![]() | ![]() |
| ------------------------------------------------------------ | ------------------------------------------------------------ |
![]() | ![]() |
主要实现介绍
各个页面的具体实现可以查看源码,这里主要介绍一些主要的实现与原理
使用MVI架构
MVI 与 MVVM 很相似,其借鉴了前端框架的思想,更加强调数据的单向流动和唯一数据源,架构图如下所示
例如登录页面的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
总结
项目地址
https://github.com/shenzhen2017/wanandroid-compose
开源不易,如果项目对你有所帮助,欢迎点赞,Star,收藏~
参考资料
https://github.com/manqianzhuang/HamApp
https://github.com/linxiangcheer/PlayAndroid
从零到一写一个完整的 Compose 版本的天气





