imcys.com
遵从中二的召唤,来吧少年!

Jetpack Compose的初次尝试

前言

Jetpack Compose的1.0发布到现在也有1年多了,我也想尝试尝试声明式UI(函数式编程)。想法是很好的,但是从命令式UI到声明式UI,并不是那么容易转过来的,我曾几次想尝试Compose,奈何Compose在UI上天翻地覆,想去弄个组件,定个位置,都比较麻烦。

但还是在最近两天咬牙试了试,今天就来做个简单的汇报吧。

复刻布局

我想了一下,既然后面有考虑使用Compose,那么不可能只搞布局,我们试试简单的网络传参更新到布局吧。

先来看看成品UI

这是BILIBILIAS的一个用户UI界面,当然,现在看到的就是已经用了Compose之后的。比较简单,我们一起来看看。

这个界面由4大块组成,我们依次拆解。

顶部导航实现

其实Compose提供了一个界面的脚手架,如果你用过Vue的一些前端框架,应该可以明白,就是给你规定好了一些组件的位置,你只需选择这里是否要填充组件进去。

没错这就是Scaffold – Jetpack Compose Docs (bughub.icu),如果你想看看它的介绍就点进去。

@Preview
@Composable
fun UserActivityLayout(
    viewModel: UserViewModel = UserViewModel(),
) {
    //--请暂时不要在意这些代码,这是请求网络数据的--
    val viewState = viewModel.viewStates
    
    LaunchedEffect(Unit) {
        viewModel.userChannel.send(UserIntent.GetUserBaseBean)
        viewModel.userChannel.send(UserIntent.GetUserCardData)
    }
    //---------------------------------------


    BILIBILIASTheme {
        Scaffold(
            topBar = {
                TopAppBar(
                    title = {
                        Text(
                            fontSize = 20.sp,
                            text = "User",

                            )
                    },
                    navigationIcon = {
                        Icon(
                            imageVector = Icons.Default.ArrowBack,
                            contentDescription = "返回"
                        )
                    },
                    backgroundColor = Color.White,
                    elevation = 0.dp
                )
            },
        ) {
            //这里是另一个合并的组件(啊,就是一个方法,里边有很多组件,暂时忽略传参)
            BodySample(it, viewModel, viewState)
        }
    }

}

这里我们用了Scaffold,并且传入了TopAppBar,这个就是顶部的工具栏了,但只是这样还不可以,我们还得为这个topbar的槽位添加内容。

我们查看下这个TopAppBar

不要头疼,我们不关注他的实现,我们只是看看这东西需要什么,title,我们需要他,因为这个TopAppBar现在还没有标题,但注意,这里的类型是个函数,而且还是被@Composable所注解的,这代表这里得放一个组件,我们自然想到是Text | 你好 Compose (jetpackcompose.cn)。当然,你也可以传其他组件,只是不太河里。

navigationIcon,导航图标,这是我们需要的,我们至少需要一个返回的按钮,但这个槽位我们要放个图标组件,Icon | 你好 Compose (jetpackcompose.cn)

OK,到这里,我们的顶部导航栏首先被完成了。

有注意到吗,Scaffold的最后我们传了个函数,在这里面就可以传入其他组件,在这里传入后相当于在下图框选出来的位置放置。

用户名和头像部分实现

在上面,我们在脚手架的内容部分调用了一个函数。

BodySample(it, viewModel, viewState)

我们还是不在意传入的参数,而是这个函数里面是什么?

这样写其实是为了将很多个UI模块分开,避免写在一起,很乱不方便维护。

@Composable
fun BodySample(
    paddingValues: PaddingValues,
    viewModel: UserViewModel,
    viewState: UserViewModel.UserViewState,
) {

    Column() {

        Column(Modifier.padding(20.dp)) {
            //请暂时无视传参
            FaceCardItem(viewModel, viewState)

            UserDataCardItem(viewModel, viewState)
        }

        UserWorkList(viewModel, viewState)


    }
}

我们发现BodySample里也有调用,但是我们先不着急,而是看看结构。

最外层是Column | 你好 Compose (jetpackcompose.cn),嗯,这个东西有点类似我们的垂直方向线性布局,但不要误会,Column,是另一个组件。

这里我们相当于垂直排布两大块内容,其中第一大块内容我们设置了内边距为20dp。

而这其中的FaceCardItem,就是我们要实现的头像部分。

/**
 * 头像部分item
 */
@Composable
fun FaceCardItem(
    viewModel: UserViewModel,
    viewState: UserViewModel.UserViewState,
) {


    Row {
        Surface(
            modifier = Modifier.size(90.dp),
            shape = RoundedCornerShape(45.dp)
        ) {
            AsyncImage(
                model = viewState.userBaseBean.data?.face,
                contentDescription = "头像",
                placeholder = painterResource(id = R.mipmap.ic_launcher),
                contentScale = ContentScale.FillBounds
            )

        }

        Spacer(modifier = Modifier
            .width(10.dp))

        Column(
            modifier = Modifier
                .fillMaxWidth()
                .height(90.dp),
            verticalArrangement = Arrangement.SpaceEvenly

        ) {
            Text(
                fontWeight = FontWeight.Bold,
                fontSize = 22.sp,
                text = viewState.userBaseBean.data?.name ?: "用户名",
                maxLines = 1,
                overflow = TextOverflow.Ellipsis
            )

            Text(
                fontWeight = FontWeight.Bold,
                fontSize = 13.sp,
                text = viewState.userBaseBean.data?.sign ?: "签名",
                maxLines = 2,
                overflow = TextOverflow.Ellipsis
            )
        }
    }
}

emmm,看起来有点复杂,但是别慌,只是因为我们现在是逆向的看布局,所以感觉很多很杂。

在这个模板里,最外层是Row | 你好 Compose (jetpackcompose.cn)Row,这也是个布局容器,类似Column,但不一样的是,这是一个横向排布的容器。

Row里边,第一个出现的是Surface | 你好 Compose (jetpackcompose.cn)Surface,可以控制布局的外观轮廓,这里我们要实现一个圆形头像,就要考虑利用Surface来裁剪一个圆。

Surface内的话自然就是头像的Image | 你好 Compose (jetpackcompose.cn)了,注意,这里的是AsyncImage,这里是因为我用了一个图片加载库——coil

implementation "io.coil-kt:coil-compose:2.2.2"

这里我只引入了对compose支持的。

AsyncImage来帮我们完成网络图片的加载,其中model就是来传入图片地址的。

接下来是个Spacer | 你好 Compose (jetpackcompose.cn)Spacer就是个空白的组件,这里是帮助我添加边距的,我知道有点离谱,但是谷歌官方就是这样的。

其实就是这部分,把用户名隔开点。

毋庸置疑的,接下来的就是个Column,因为我们的用户名和签名是垂直排布的。

但是注意,我们加了一个新的东西。

 verticalArrangement = Arrangement.SpaceEvenly

其实这个是在设置子view的排布方式,有了它,就不需要设置间距,而让用户名和签名均匀排布。

看看这Column – Jetpack Compose Docs (bughub.icu),有动画很形象。

到这,我们的头像部分也结束了。

粉丝数据部分实现

回到BodySample,在FaceCardItem下面还有UserDataCardItem,我们看看UserDataCardItem

    Spacer(modifier = Modifier.height(10.dp))

    Row() {
        Column(
            modifier = Modifier
                .width(0.dp)
                .weight(1f, fill = true)
                .padding(10.dp),
            horizontalAlignment = Alignment.CenterHorizontally,

            ) {

            Text(
                fontWeight = FontWeight.Bold,
                fontSize = 18.sp,
                text = (viewState.userCardBean?.data?.card?.fans ?: 0).digitalConversion()
            )

            Spacer(modifier = Modifier.height(6.dp))

            Text(
                color = ColorTextHint,
                fontWeight = FontWeight.Bold,
                fontSize = 14.sp,
                text = "粉丝"
            )

        }

        Column(
            modifier = Modifier
                .width(0.dp)
                .weight(1f, fill = true)
                .padding(10.dp),
            horizontalAlignment = Alignment.CenterHorizontally,

            ) {

            Text(
                fontWeight = FontWeight.Bold,
                fontSize = 18.sp,
                text = (viewState.userCardBean?.data?.card?.friend ?: 0).digitalConversion()

            )

            Spacer(modifier = Modifier.height(6.dp))

            Text(
                color = ColorTextHint,

                fontWeight = FontWeight.Bold,
                fontSize = 14.sp,
                text = "关注"
            )

        }

       //省略剩下两个


    }

这里我们只需要注意的是新修饰

weight(1f, fill = true)

怎么样,是不是有点当时xml的味道了,我们让4个Column平均分布,然后在里边写好粉丝数和粉丝标签,就OK啦。其他几个以此类推。

实现用户作品瀑布流列表

最后一个只能是没提到的UserWorkList了。

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UserWorkList(viewModel: UserViewModel, viewState: UserViewModel.UserViewState) {
    //---传参就不看了---
    val workQn by remember {
        mutableStateOf(1)
    }

    LaunchedEffect(key1 = true) {
        viewModel.userChannel.send(UserIntent.GetUserWorksBean(workQn, 20))
    }
    //-------------------
    Spacer(modifier = Modifier.height(20.dp))

    Surface(
        shape = RoundedCornerShape(
            topEndPercent = 5,
            topStartPercent = 5
        ),
        modifier = Modifier
            .fillMaxHeight()
            .fillMaxWidth()
    ) {


        LazyVerticalStaggeredGrid(
            columns = StaggeredGridCells.Fixed(2),
            contentPadding = PaddingValues(20.dp, 20.dp),
            // item 和 item 之间的纵向间距
            verticalArrangement = Arrangement.spacedBy(20.dp),
            // item 和 item 之间的横向间距
            horizontalArrangement = Arrangement.spacedBy(20.dp),
            modifier = Modifier
                .fillMaxHeight()
                .background(UserWorkBg),
        ) {

            item {
                Text(
                    text = "作品",
                    fontSize = 20.sp,
                    fontWeight = FontWeight.Bold,
                    letterSpacing = 0.1.sp
                )
            }
            item {
                Spacer(modifier = Modifier.fillMaxWidth())
            }

            viewState.userWorksBean?.data?.list?.vlist?.forEach { vlistBean ->
                item(key = vlistBean.bvid) {
                    UserWorkSample(vlistBean)
                }
            }


        }


    }
}

@SuppressLint("InvalidColorHexValue")
@Composable
fun UserWorkSample(vlistBean: UserWorksBean.DataBean.ListBean.VlistBean) {

    Card(

        modifier = Modifier
            .fillMaxWidth(),
        elevation = 0.dp,
        backgroundColor = Color.White,
        shape = RoundedCornerShape(5.dp)

    ) {
        Column {

            Box(modifier = Modifier
                .fillMaxWidth()
                .fillMaxHeight()) {

                AsyncImage(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(100.dp),
                    model = vlistBean.pic,
                    contentDescription = "视频封面",
                    contentScale = ContentScale.FillBounds
                )

                Row(
                    Modifier
                        .padding(1.dp)
                        .fillMaxWidth()
                        .align(Alignment.BottomCenter)
                        .background(Color(0xFF80a1a3a6)),
                ) {


                    Spacer(modifier = Modifier.width(10.dp))

                    Icon(painter = painterResource(id = R.drawable.ic_play_num),
                        contentDescription = "播放量图标",
                        modifier = Modifier
                            .size(14.dp)
                            .align(Alignment.CenterVertically),
                        tint = Color.White
                    )

                    Spacer(modifier = Modifier.width(3.dp))

                    Text(text = vlistBean.play.digitalConversion(),
                        fontSize = 10.sp,
                        color = Color.White)

                    Spacer(modifier = Modifier.width(10.dp))

                    Icon(painter = painterResource(id = R.drawable.ic_danmaku_num),
                        contentDescription = "弹幕数图标",
                        modifier = Modifier
                            .size(14.dp)
                            .align(Alignment.CenterVertically),
                        tint = Color.White
                    )

                    Spacer(modifier = Modifier.width(3.dp))

                    Text(text = vlistBean.video_review.digitalConversion(),
                        fontSize = 10.sp,
                        color = Color.White)

                }

            }

            Column(modifier = Modifier.padding(5.dp)) {

                Text(text = vlistBean.title, fontSize = 14.sp,
                    fontWeight = FontWeight.Bold,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis
                )

            }

        }

    }

}

内容比较多,我们先看看这个UserDataCardItem吧,其中Surface来构建外部的一个灰色圆角背景。

 shape = RoundedCornerShape(
            topEndPercent = 5,
            topStartPercent = 5
        ),

这是对几个顶点的单独圆角设置。

接下来就是内部的LazyVerticalStaggeredGrid | 你好 Compose (jetpackcompose.cn)LazyVerticalStaggeredGrid是个瀑布流组件,并且支持懒加载的,在Compose1.3.1加入。

LazyVerticalStaggeredGrid中,我们可以看到首先有个item,实际上在列表或者网格布局里,我们只能在item中来加入组件,这是因为这些组件并不能直接塞组件进去,因为这些组件之间传递是没有Composable上下文的,而是通过item获取到了@Composable的上下文,所以才能在item里加组件,其他组件也是这样。

我们为了实现这个标题,所以用了一个TextSpacer,其中Spacer是为了占一个位置,因为在LazyVerticalStaggeredGrid中,我们设置了一行展示2个item。这么做是因为目前LazyVerticalStaggeredGrid不支持item横跨,当然也许是我没发现怎么做。

接下来我们通过一个forEach,不断添加item,而这个item则是用户的视频卡片。

也就是UserWorkSample,我们看着上面的代码,在UserWorkSample中,用了一个新组件是Card | 你好 Compose (jetpackcompose.cn),老朋友了,对吧,就不多说了。

Card内部还有个Box – Jetpack Compose Docs (bughub.icu)Box,有点像是那个帧布局,这里我们是为了让图片的上面可以盖一个显示播放数和弹幕数的横幅。

其他代码就没什么好说的了。

不足之处

假设数据足够多,我们发现滚动这个LazyVerticalStaggeredGrid,内容只能在下面这边地方滚动,并不能带动上面的头像部分布局滚动,大家可能说,在上面套个滚动布局不就可以了?

如果可以我们前面就使用了,很遗憾,目前Compose没解决惰性布局(列表,滚动)的嵌套,如果嵌套了同一滚动方向的容器,就会闪退出错,这是似乎因为父容器不知道你新的滚动布局有多少内容,有多长,导致了这样。

这样说很抽象,我们看这个视频Jetpack Compose 嵌套滚动_哔哩哔哩_bilibili,视频里边说了这个问题。

并且我在谷歌官网看到了这个

好吧,比较可惜,我现在也不知道如何解决,如果有大佬有其他方案欢迎告诉我。

MVI框架初尝试

emmm,只能说变的好快啊,我mvvm都没玩熟。

好吧,我们这里简单看看mvi,以及快速的过一遍,把数据和布局穿起来。

简单看看流程

不要担心,我简单说说,我们马上对应到代码上。

可以看到数据单向流动了,哈哈。

首先,我作为用户,想要得到用户的头像和名称,就要发送一个意图,就是上图action,但是这个意图对应着一个叫Intent类的内部类或者内部对象,目的就是得让这个意图有个对应的对象,我们得知道这个意图是做什么的吧?

接下来ViewModel一般得监听这个东西,一但有也就是Intent对象传递,就要分析是什么,然后执行对应的操作,比如这个意图获取用户信息,那么ViewModel就执行对应网络请求拿到这个返回对象,然后更新State的内容。这对应的就是图中的ModelView这部分。

那么View的数据从哪里来?哈哈,自然就是ViewModel中的State类,这个类其实就是储存view需要的数据或者用户输入的数据

因此,当ViewModel更新State后,View视图数据自然也被更新了。

最后一段,就是View的一些事件,传递出去,其实这里也是一个意图

ViewModel的实现

//意图
sealed class UserIntent {
    object GetUserBaseBean : UserIntent()
    object GetUserCardData : UserIntent()
    data class GetUserWorksBean(var qn: Int, var ps: Int = 20) : UserIntent()
}

class UserViewModel : ViewModel() {


    data class UserViewState(
        var userBaseBean: UserBaseBean = UserBaseBean(),
        var userCardBean: UserCardBean? = UserCardBean(),
        var upStatBeam: UpStatBeam? = UpStatBeam(),
        var userWorksBean: UserWorksBean? = UserWorksBean(),
    )


    val userChannel = Channel<UserIntent>(Channel.UNLIMITED)
    var viewStates by mutableStateOf(UserViewState())
        private set


    init {
        //监听管道
        handleIntent()
    }

    private fun handleIntent() {
        viewModelScope.launch {
            userChannel.consumeAsFlow().collect() {
                when (it) {

                    is UserIntent.GetUserBaseBean -> getUserBaseData()
                    is UserIntent.GetUserCardData -> getUserCardData()
                    is UserIntent.GetUserWorksBean -> getUserWorksBean(it.qn, it.ps)
                }
            }
        }
    }


    private fun getUserBaseData() {
        viewModelScope.launch {
            latestUserBaseData.flowOn(Dispatchers.Default)
                .catch {
                }.collect {
                    viewStates = viewStates.copy(
                        userBaseBean = it
                    )
                }

        }
    }


    private fun getUserWorksBean(qn: Int, ps: Int) {
        viewModelScope.launch {
            val userWorksBean = withContext(viewModelScope.coroutineContext) {
                return@withContext HttpUtils.asyncGet("${BilibiliApi.userWorksPath}?mid=${BaseApplication.mid}&qn=$qn&ps=$ps",
                    UserWorksBean::class.java)
            }
            viewStates = viewStates.copy(
                userWorksBean = userWorksBean
            )
        }
    }


    private fun getUserCardData() {
        viewModelScope.launch {
            latestUserCardData.flowOn(Dispatchers.Default)
                .catch {
                }.collect {
                    viewStates = viewStates.copy(
                        userCardBean = it
                    )
                }

        }
        getUpStatData()
    }

    private fun getUpStatData() {
        viewModelScope.launch {
            latestUpStatBeamData.flowOn(Dispatchers.Default)
                .catch {
                }.collect {
                    viewStates = viewStates.copy(
                        upStatBeam = it
                    )
                }

        }
    }


    private val latestUserBaseData: Flow<UserBaseBean> = flow {
        val userBaseBean = withContext(Dispatchers.IO) {
            HttpUtils.addHeader("cookie", BaseApplication.cookies)
                .asyncGet("${BilibiliApi.userBaseDataPath}?mid=${BaseApplication.mid}",
                    UserBaseBean::class.java)
        }
        //返回拉取结果
        emit(userBaseBean)
    }

    private val latestUserCardData: Flow<UserCardBean> = flow {
        val userCardBean = withContext(Dispatchers.IO) {
            HttpUtils.addHeader("cookie", BaseApplication.cookies)
                .asyncGet("${BilibiliApi.getUserCardPath}?mid=${BaseApplication.mid}",
                    UserCardBean::class.java)
        }
        //返回拉取结果
        emit(userCardBean)
    }

    private val latestUpStatBeamData: Flow<UpStatBeam> = flow {
        val upStatBeam = withContext(Dispatchers.IO) {
            HttpUtils.addHeader("cookie", BaseApplication.cookies)
                .asyncGet("${BilibiliApi.userUpStat}?mid=${BaseApplication.mid}",
                    UpStatBeam::class.java)
        }
        //返回拉取结果
        emit(upStatBeam)
    }


}

UserViewModel就是刚刚用户界面的ViewModel

而其中UserIntent,就是这个动作意图的集合,里面是设置了一些可选的意图,可以看到其实UserIntent里的东西就是相当于一个标签,让ViewModel确定只是什么动作,别着急,我们下面就说这个。注意,这可不是个接口类

//意图
sealed class UserIntent {
    object GetUserBaseBean : UserIntent()
    object GetUserCardData : UserIntent()
    data class GetUserWorksBean(var qn: Int, var ps: Int = 20) : UserIntent()
}

我们再看看UserViewModel类,映入眼帘的就是State了。

    data class UserViewState(
        var userBaseBean: UserBaseBean = UserBaseBean(),
        var userCardBean: UserCardBean? = UserCardBean(),
        var upStatBeam: UpStatBeam? = UpStatBeam(),
        var userWorksBean: UserWorksBean? = UserWorksBean(),
    )

可以看到,这其实就是view需要的一些Bean类

 val userChannel = Channel<UserIntent>(Channel.UNLIMITED)
 var viewStates by mutableStateOf(UserViewState())
        private set

这里我们用了一个Channel,利用它配合协程完成意图传递收集

同时我们也初始化一个viewStates,这是view要用的,委托给mutableStateOf,让这个viewStates的变化是可以被监听的,这就是为什么改变viewStates会导致view变动。

 init {
        //管道数据获取
        handleIntent()
    }

    private fun handleIntent() {
        viewModelScope.launch {
            userChannel.consumeAsFlow().collect() {
                when (it) {
                    is UserIntent.GetUserBaseBean -> getUserBaseData()
                    is UserIntent.GetUserCardData -> getUserCardData()
                    is UserIntent.GetUserWorksBean -> getUserWorksBean(it.qn, it.ps)
                }
            }
        }
    }

这里,我们在初始化时就调用了handleIntent,来获取这个Channel里的UserIntent意图,我们知道,如果调用receive()如果Channel有东西就消费,如果没就挂起,那么这样的话就得用个while循环一直饿汉式的拿了,我们换一下,把Channel改为Flow流,也就是consumeAsFlow(),当调用collect()时,上流的代码执行,但是吧上流现在是Channel转换的flow流,通过消费者的不断挂起和执行,来获取数据。

这样方法相当巧妙,将ChannelFlow结合在了一起。

我们拿到传入的意图后就判断是什么,然后执行对应代码,如果getUserBaseData()

    private fun getUserBaseData() {
        viewModelScope.launch {
            latestUserBaseData.flowOn(Dispatchers.Default)
                .catch {
                }.collect {
                    viewStates = viewStates.copy(
                        userBaseBean = it
                    )
                }

        }
    }

     private val latestUserBaseData: Flow<UserBaseBean> = flow {
        val userBaseBean = withContext(Dispatchers.IO) {
            HttpUtils.addHeader("cookie", BaseApplication.cookies)
                .asyncGet("${BilibiliApi.userBaseDataPath}?mid=${BaseApplication.mid}",
                    UserBaseBean::class.java)
        }
        //返回拉取结果
        emit(userBaseBean)
    }

getUserBaseData()是收集latestUserBaseData这个Flow的数据,其实这也可以不用Flow,协程挂起也可以了。可以参考getUserWorksBean方法。

这的HttpUtils是封装了okhttp的,后面放代码。

其他意图也以此类推。

 viewStates = viewStates.copy(userBaseBean = it)

通过这个方法来更新States

布局绑定States

UserActivityLayout,我们看之前的布局代码,怎么样是不是可以对应上了?

//获取viewModel的States
val viewState = viewModel.viewStates
LaunchedEffect(Unit) {
      //意图发出,获取用户信息
      viewModel.userChannel.send(UserIntent.GetUserBaseBean)
      viewModel.userChannel.send(UserIntent.GetUserCardData)
}

意图发出,viewModel受理执行获取数据,并且更新States后,如上图的布局,就会被更新,同时展示出数据。

文末

最后如果大家发现有问题就指出告诉我,希望Jetpack Compose快快成长起来,很高兴能给我带来一个新的体验。

萌新杰少

文章作者

I im CYS,一个热爱二次元的大专开发者

发表回复

textsms
account_circle
email

Captcha Code

萌新杰少の秘密基地

Jetpack Compose的初次尝试
Jetpack Compose的1.0发布到现在也有1年多了,我也想尝试尝试声明式UI(函数式编程)。想法是很好的,但是从命令式UI到声明式UI,并不是那么容易转过来的,我曾几次想尝试Compose,奈何Compose在UI上天翻地覆,想去弄个组件,定个位置,都比较麻烦。
扫描二维码继续阅读
2023-01-28