Skip to content

GitJoBo/wanandroid-compose

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 Cannot retrieve latest commit at this time.

History

46 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

前言

今年七月底,Google 正式发布了 Jetpack Compose1.0 稳定版本,这说明Google认为Compose已经可以用于生产环境了。相信Compose的广泛应用就在不远的将来,现在应该是学习Compose的一个比较好的时机
在了解了Compose的基本知识与原理之后,通过一个完整的项目继续学习Compose应该是一个比较好的方式。本文主要基于Compose,MVI架构,单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又回到了列表顶部

LazyColumn出现这个问题的原因主要在于它用于记录滚动位置的参数LazyListState没有做持久化保存,当重新回到A页面时,LazyListState数据重新变为默认值0,自然就回到顶部了,如下图所示

既然原因在于LazyListState没有被保存,那我们将LazyListSate保存在ViewModel中就可以了,如下所示

@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()
    // val listState = viewStates.listState //一般这样就够了
    // 当使用`Paging`时的特殊处理,一般直接使用viewStates.listState即可    
    val listState = if (squareData.itemCount > 0) viewStates.listState else LazyListState()

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

需要注意的是,针对一般的页面,直接使用viewModel.listState即可,不过我在使用Paing时发现返回页面时PagingitemCount会暂时变为0,导致listState也变为0,所以需要做一些特殊处理
关于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 版本的天气

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Kotlin 100.0%