海外直播、聊天交友APP的开发及上架GooglePlay体验【Compose版】

前言

Jetpack Compose在2021年7月底的时候正式发布了Release 1.0版本,在8月中旬的时候正好赶上公司海外项目计划重构,于是主动请缨向领导申请下来了此次开发的机会。由于之前一直在关注Compose,所以直接扬言要使用Compose来完成全部的UI(事实上也基本达到了目的)。
原本的项目是2017年基于Java+MVP++等的架构,此次则全部推倒重来,基于Kotlin+MVVM/MVI+Jetpack++等的架构。

此次项目重构涉及的技术点如下:

  • Kotlin

Coroutines、Flow、Coil(图片加载库)、Moshi(Json解析库)

  • Jetpack

Compose、Accompanist、Paging、ViewModel、Lifecycle、Room、Hilt、LiveDataViewBindingConstraintLayout

  • 架构

MVVM、MVI

  • 其他

Retrofit、ViewPager2、ARouter、MMKV、Firebase SDK、Google SDK、Facebook SDK、AWS SDK等

以上基本就是此次开发中所涉及的知识点了,其中有几个标记了中划线,这是因为后期开发中发现他们的作用越来越小了,基本可以完全去除。
接下来就是详细的内容了,先从Compose说起,因为这个东西完全改变了我们以往的UI开发体验。而且使用中也遇到了诸多问题,所以本文会介绍下自己在项目中的解法,抛砖引玉的同时也希望能在大家的建议下更上一层楼。

Compose篇

大家可能或多或少的都已经阅读到过关于Compose的各种文章了,本篇文章不会着重讲解Compose的使用,主要是分析并解决一些实际开发中遇到的问题。
这里要提一嘴的是,该项目中仅仅是使用Compose替换了View那一套体系,并不是单Activity的Compose项目,也没有使用Navigation来处理Compose的导航问题,所以期待全Compose实现的朋友可能要失望了。

1、TextField And Keyboard

相信大家在View的体系下都处理过EditText和Keyboard的奇奇怪怪的问题,同样的,在Compose中也会有相关问题。
一个最简单的TextField实现如下:

TextField(
    value = "This is TextField",
    onValueChange = {}
)

它渲染出来的样式如下:

Snipaste_2021-12-01_16-45-32.png

如你所见,这个TextField实在是没办法满足大部分UI的需求,而且它的可定制性几乎为0。当我们更改其Shape为圆角属性并强行设置TextField的高度时,它的渲染效果居然如下图所示:

Snipaste_2021-12-01_16-58-13.png

点进去TextField的源码可以看到只有TextStyle、TextFieldColors、Shape等可供我们自行实现。按着源码一路找下去,发现在TextFieldLayout下已经强行将TextField的最低高度限制为了 MinHeight = 56.dp。

海外直播、聊天交友APP的开发及上架GooglePlay体验【Compose版】_第1张图片

所以,综上所述,针对TextField我们强烈建议使用BasicTextField进行统一的自定义以满足项目UI的需求。至于如何自定义,网上文章已经很多了,这里不再赘述。

OK,说完了样式问题,接下来还有键盘的问题。

当我们做聊天页面的时候,输入框是在屏幕底部的,此时弹出键盘就会遇到问题了,如下所示:

海外直播、聊天交友APP的开发及上架GooglePlay体验【Compose版】_第2张图片

键盘会对齐到输入框中文字的底部,我们肯定不想要这样的效果,正常起码应该是将整个圆角矩形显示完全。此时在清单文件中设置软键盘的模式为 android:windowSoftInputMode="adjustResize"即可(居然还有xml)。当你的手机有导航栏或者输入框下方需要添加其他UI等的时候,可以参考下方章节中Insets依赖库提供的相关修饰符,如imePadding(),navigationBarsWithImePadding()等进行优化。

设置完后,TextField可以正常显示了。但是我就是事儿多,想进入页面的时候立刻就弹出键盘,期望用户进行输入。点击非输入框区域就隐藏键盘,那么我们可以使用如下的方式来让输入框显示的时候就获取焦点:

val focusRequester = FocusRequester()

LaunchedEffect(key1 = Unit, block = {
    focusRequester.requestFocus()
})

TextField(
    modifier = Modifier
        .focusRequester(focusRequester = focusRequester)
        .onFocusChanged {}
        .onFocusEvent {}
)

上述代码已经进行了精简,首先我们需要创建一个FocusRequester对象,然后传递给操作符focusRequester。当该组合函数首次进入组合时,LaunchedEffect会被触发,从而进行获取焦点的请求,所以此时TextField会获取焦点并且键盘会直接弹出(关于LaunchedEffect等请参考8、Side-effects章节)。

那么当用户希望隐藏键盘时如何处理呢?使用LocalFocusManager:

val localFocusManager = LocalFocusManager.current

TextField(
    onValueChange = {
        if (it == "sss") {
            localFocusManager.clearFocus()
        }
    },
)

如上所示,当我们在TextField中输入了 sss 后触发条件,LocalFocusManager.clearFocus() 会清空焦点,键盘则会同步隐藏,效果如下所示:

海外直播、聊天交友APP的开发及上架GooglePlay体验【Compose版】_第3张图片

2、LazyVerticalGrid And Paging

除了LazyRow和LazyColumn外,Compose还提供了LazyVerticalGrid可以帮助我们实现表格列表,其实点进去源码查看其最终还是使用了LazyColumn进行了实现。所以使用方式上基本类同LazyColumn,搭配Accompanist的 Swipe to Refresh 依赖库也是没有问题的。

相信在XML的时代,大家肯定被RecyclerView、Adapter支配过,再加上下拉刷新,上拉加载,代码是牵一发动全身。但是在Compose中,开发这种情形的话代码量骤减,效率暴增!!!下面我们通过Paging依赖库分别简单示例从本地和远程获取分页数据:

  • 本地分页数据

我们以Room中存储的聊天消息列表为例,Room直接支持获取到DataSource.Factory类型的分页数据,如下所示:

@Query("SELECT * FROM message ORDER BY timestamp DESC")
fun queryMessageList(): DataSource.Factory<Int, MessageEntity>

然后我们将使用 **Pager **类其处理成返回Flow类型的数据:

protected fun <T : Any> pageDataLocal(
    dataSourceFactory: DataSource.Factory<Int, T>
): Flow<PagingData<T>> = Pager(
    config = PagingConfig(pageSize = 20),
    pagingSourceFactory = dataSourceFactory.asPagingSourceFactory()
).flow  

然后在Compose中将Flow类型的数据转换为LazyPagingItems类型给LazyColumn、LazyRow或者LazyVerticalGrid使用,这些在paging-compose依赖中有提供,整个基本的聊天消息列表可能就如下这么简单:

val messageList = vm.messageList.collectAsLazyPagingItems()

LazyColumn {
    items(messageList) {
        //your item content
    }
}
  • 远程分页数据

当然了,还有很多列表数据都是需要请求服务器的,那么实现这种就稍微复杂了一点。同样的我们需要先获取到Flow类型的数据,但是不像Room那样我们可以直接拿到DataSource.Factory的数据,这里我们得通过继承PagingSource自行处理,伪代码如下,重点在load()函数:

abstract class BasePagingSource<T : Any> : PagingSource<Int, T>() {

    //...省略其他内容

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, T> {
        return try {

            //下一页的数据,比如业务中是从第1页开始
            val nextPage = params.key ?: 1

            //获取到的请求结果
            val apiResponse = apiPage(nextPage)

            //总页数
            val totalPage = apiResponse.result.totalPage

            //如果不为空
            LoadResult.Page(
                data = listData,
                prevKey = if (nextPage == 1) null else nextPage - 1,
                nextKey = if (nextPage < totalPage) nextPage + 1 else null
            )

        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    //暴漏出去的获取服务端数据的方法
    abstract suspend fun apiPage(pageNumber: Int): ApiResponse<PageResult<T>>

}

其中服务端需要提供给我们一些基本信息,例如数据的总页数,当前的页数等信息,另外也要注意数据的规范性,列表的数据为空时数据是null还是emptyList等。这个时候怎么分页已经搞定了,我们同样使用 **Pager **类其处理成返回Flow类型的数据:

protected fun <T : Any> pageDataRemote(
    block: suspend (pageNumber: Int) -> ApiResponse<PageResult<T>>
): Flow<PagingData<T>> = Pager(
    config = PagingConfig(pageSize = 20)
) {
    object : BasePagingSource<T>() {
        override suspend fun apiPage(pageNumber: Int): ApiResponse<PageResult<T>> {
            return block(pageNumber)
        }
    }
}.flow

到这里,后续的处理就又都同上了。整体封装下来,从Model层到ViewModel层几乎可以实现几行代码搞定,V层则看实际的UI复杂程度了,使用起来简直不要太舒服。

下拉刷新怎么实现呢?可以参考Accompanist中的Swipe to Refresh依赖库,具体使用方法请参考官方示例,我们使用其提供的onRefresh()回调接口,直接调用LazyPagingItems类中的**refresh()**函数即可实现下拉刷新的功能。

3、SystemBar(StatusBar、NavigationBar)

关于透明状态栏以及沉浸式状态栏等,在原来使用View体系的时候我们有各种工具类,在Compose中官方也贴心为我们提供了解决方案,有请【Accompanist】。

Accompanist is a group of libraries that aim to supplement Jetpack Compose with features that are commonly required by developers but not yet available.

Accompanist 是一组旨在补充Jetpack Compose功能库的集合。(有些开发中常见的功能我们需要但是Compose还未提供,那么我们就可以先看下Accompanist是否提供了)

目前Accompanist提供了如:Insets、System UI Controller、Swipe to Refresh、Flow Layouts、Permissions等等功能的库,这里我们只需要Insets和System UI Controller。

OK,先来说说状态栏的颜色及图标颜色控制,导入依赖 implementation"com.google.accompanist:accompanist-systemuicontroller:",我们设置状态栏颜色为白色,图标颜色为黑色:

val systemUiController = rememberSystemUiController()

SideEffect {
    systemUiController.setStatusBarColor(
        color = Color.White,
        darkIcons = true,
    )
}

显示结果如下左图所示,更改状态颜色为黑色,图标为白色后,显示结果如下右图所示:
海外直播、聊天交友APP的开发及上架GooglePlay体验【Compose版】_第4张图片

海外直播、聊天交友APP的开发及上架GooglePlay体验【Compose版】_第5张图片

如果要控制图片沉浸到状态栏呢?重点在这里 WindowCompat.setDecorFitsSystemWindows(window, false),这样我们就可以让内容区域延伸到状态栏,然后我们给状态栏设置透明色,并使用白色图标,那么显示结果就如下所示了。

Snipaste_2021-12-02_10-49-05.png

但是还有一个问题,就是标题区域也延伸到了状态栏,我们的需求是图片背景延伸到状态栏,但是标题区域需要在状态栏下方。

还是借助Accompanist,导入Insets依赖:implementation"com.google.accompanist:accompanist-insets:",Insets可以帮助我们方便的测量出状态栏,导航栏,键盘等的高度。

首先需要使用ProvideWindowInsets 包裹我们的组合函数,如下所示:

setContent {
  MaterialTheme {
    ProvideWindowInsets {
      // your content
    }
  }
}

然后我们使用Box布局,设置一张背景图片以及一个状态栏,伪代码如下:

ProvideWindowInsets {
    Box(modifier = Modifier.fillMaxSize()) {
        //background image
        Image()

        //content
        Column(
            modifier = Modifier
                .fillMaxSize()
                .statusBarsPadding()
        ) {

            //app bar / title Bar
            Text(text = "Compose Title")
        }
    }
}

注意两点:ProvideWindowInsets需要在可组合函数的最顶层,内容区域的可组合函数使用了statusBarsPadding()操作符。这是Insets给我们提供的操作符,该操作符的作用就是给Column的内容区域顶部添加一个状态栏高度的间距,那么其内部的AppBar就会显示到了状态栏的下面,如下图所示:

Snipaste_2021-12-02_11-15-03.png

当然,处理这种情况的话还有一个方法,使用Insets提供的状态栏的另一个操作符:statusBarsHeight(),修改上面伪代码的content区域:

//content
Column(
    modifier = Modifier
        .fillMaxSize()
) {

    //add a placeholder
    Spacer(
        modifier = Modifier
            .fillMaxWidth()
            .statusBarsHeight()
    )

    //app bar / title Bar
    Text(text = "Compose Title")
}

我们给内容的顶部添加了一个占位符Spacer,并设置其高度就是状态栏的高度,这样也可以达到上面的效果。实际开发中我们会经常需要这种沉浸式的UI,所以采用第一种直接使用操作符与第二种添加占位符的方式都没有问题,个人倾向于第二种,添加一个开关参数控制Spacer的显示与否。

关于导航栏以及键盘等,Insets都给我们提供了相应的操作符,如下:

  • Modifier.statusBarsPadding()
  • Modifier.navigationBarsPadding()
  • Modifier.systemBarsPadding()
  • Modifier.imePadding()
  • Modifier.navigationBarsWithImePadding()
  • Modifier.cutoutPadding()
  • Modifier.statusBarsHeight()
  • Modifier.navigationBarsHeight()
  • Modifier.navigationBarsWidth()

4、ComposeView And AndroidView

虽然该App的UI全部使用Compose进行开发,但是在开发中难免需要View和Compose进行互转。比如在Fragmen,DialogFragment中,onCreateView()函数接收的是一个View类型,那么我们需要做的就是使用ComposeView,如下,然后在setContent{}函数中使用Compose即可:

override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View? {

    return ComposeView(requireContext()).apply {
        setContent {
            //your compose ui
        }
    }
}

还有另一种相反的情况,比如我们在Compose中需要使用一些View体系下的控件时,例如SurfaceView、TextureView等,Compose还未提供相应的控件,所以针对这种方式我们需要使用AndroidView来处理,如下伪代码,PlayerView是封装了TextureView等的视频播放器视图,通过factory创建相应的播放器视图,然后在update中可以处理该播放器,控制其开、关、静音等逻辑:

AndroidView(
    factory = {
        PlayerView(it).apply {
            initPlayer(player = mediaPlayer)
        }
    },
    update = {
        it.play(
            url = playUrl,
            mute = false
        )
    },
    modifier = Modifier
        .fillMaxSize()
        .clip(RoundedCornerShape(10.dp))
)

5、Preview And Theme

接下来是Compose组件的预览,一般情况下我们在组合函数上使用 @Preview 注解来标记就可以实现一个可组合函数到视图的预览,单纯的预览没啥可多说的,我们也结合下主题来多讲点。

首先是DarkThemeLightTheme,Compose给我们提供了开箱即用的主题切换功能,但是必须得按照MaterialTheme的规范来,这就有点小局限了。所以如果有需要的话我们可以采用同样的方式来实现自己的一套规范,这样可自定义性就更大了(具体实现原理可以类似参考下一小节:6、CompositionLocal)。

我们使用Compose提供的MaterialTheme来实现两种不同主题的预览,这里我们工程命名为了ComposeShare,当工程创建完毕后,Compose会帮助我们生成ComposeShareTheme的组合函数,里面包含了我们的一些主题元素,颜色、字体、形状等,我们单纯使用颜色数据进行主题的展示:

@Composable
fun Test() {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .background(color = MaterialTheme.colors.background)
            .padding(all = 16.dp)
    ) {
        Text(
            text = "This is text content",
            color = MaterialTheme.colors.secondaryVariant,
            fontSize = 16.sp,
            fontWeight = FontWeight.Bold
        )
    }
}

上述代码中我们可以看到,涉及到颜色的参数我们都是使用的MaterialTheme主题下的颜色,打开工程theme文件夹下的Theme.kt文件,这其中就定义了DarkThemeLightTheme的相关颜色,我们将上述两个主题中的background,secondaryVariant参数分别互相定义为黑、白两种对比色。
然后使用Preview注解,注意添加uiMode参数,还要注意,必须使用ComposeShareTheme包裹你的内容,否则主题预览是没有效果的:

@Preview(uiMode = UI_MODE_NIGHT_YES)
@Composable
fun PreviewTestNight() {
    ComposeShareTheme {
        Test()
    }
}

@Preview(uiMode = UI_MODE_NIGHT_NO)
@Composable
fun PreviewTestLight() {
    ComposeShareTheme {
        Test()
    }
}

实际的预览结果如下所示:

海外直播、聊天交友APP的开发及上架GooglePlay体验【Compose版】_第6张图片

组合函数的预览以及暗夜模式的切换就是这么简单了,难点在于我们App的一套主题的规范,无规范则寸步难行啊。

关于无法预览的异常情况说明:
在原来View的体系中,我们自定义View的时候,可能会遇到某些情况下无法预览的问题,IDE就会提示可能需要我们添加isInEditMode()的判断,这样如果是在AS的预览页面,那么某些导致无法预览的代码则不会执行,从而让我们可以正常预览到视图。

然而在Compose中,目前还没发现这样的功能。

我们来看一个很不规范的示例,MMKV可能大家都有在项目中使用吧。假如我存了一个键为name的String值到MMKV中,然后我有一个可组合函数就是单纯为了显示这个值的,所以我在可组合函数内直接就通过MMKV去拿这么个值显示了,伪代码如下(开发中万万不可如此使用!!!):

@Composable
fun MmkvSample() {
    val name: String = MMKV.defaultMMKV().decodeString("name")
    Text(text = name)
}

那么此时我们去预览这个函数的话,预览就会失败,AS给出了这样的错误提示:
java.lang.IllegalStateException: You should Call MMKV.initialize() first. 确实是这样,因为MMKV必须在Application中初始化才可以使用,所以在AS的预览中会遇到这个错误也是不足为奇了。

还有一个很不规范的示例,就是在Compose中使用ViewModel,有些ViewModel是有参数的,比如Repository等,这时候预览也可能会出错。

所以组合函数中尽量做到只和状态相关,不要掺杂一些其他逻辑。但是、但是如果真的有需要,就像上面那种不规范的情况,建议抽出逻辑放到参数中做一层封装,区分是预览情况还是非预览情况,类似View中的isInEditMode()。如果是预览情况,那么就走mock逻辑,返回mock的值,不要走MMKV那一套就可以避免这种问题。

6、CompositionLocal

考虑下这种情况,假如我们需要实现如下的视图,红色Box中有文本text,而外层的蓝、绿Box却跟text完全无关。然而一般情况下我们只能将text参数层层传递,从蓝色,传递到绿色,再到红色。

海外直播、聊天交友APP的开发及上架GooglePlay体验【Compose版】_第7张图片

伪代码如下所示(虽然上述视图可以直接在一个可组合函数中完成,但是为了说明实际开发业务中的一些复杂UI,这里我们用如下比较繁琐的层层嵌套的方式进行演示说明):

@Composable
fun LocalScreen() {
    BlueBox(text = "Hello")
}

@Composable
fun BlueBox(text: String) {
    Box() {
        GreenBox(text = text)
    }
}

@Composable
fun GreenBox(text: String) {
    Box() {
        RedBox(text = text)
    }
}

@Composable
fun RedBox(text: String) {
    Box() {
        Text(text = text)
    }
}

目前才只有三层,假如我们的小组件位于很底层的话,那么其需要的参数岂不是更要层层传递进来,这样的话,整个视图树中不需要这些参数的节点也需要帮忙显示的定义并传递这些参数,这在开发中会很让人头疼。

那么Compose其实也考虑到这点,解决方案就是CompositionLocal,简单来说就是它允许我们隐式的传递参数,怎么做到呢?直接看如下伪代码:


val LocalString = compositionLocalOf { "hello" }

@Composable
fun LocalScreen() {
    CompositionLocalProvider(LocalString provides "Just Hello") {
        BlueBox()
    }
}

@Composable
fun BlueBox() {
    Box() {
        GreenBox()
    }
}

@Composable
fun GreenBox() {
    Box() {
        RedBox()
    }
}

@Composable
fun RedBox() {
    Box() {
        val text = LocalString.current
        Text(text = text)
    }
}

上述代码运行后文本区域则会显示“Just Hello”,其中有几个需要注意的地方:

  • val LocalString = compositionLocalOf { “hello” }

我们使用compositionLocalOf API创建了一个CompositionLocal对象,赋值给了LocalString(还有另一种方式是staticCompositionLocalOf );

  • CompositionLocalProvider(LocalString provides “Just Hello”)

使用CompositionLocalProvider API给创建的LocalString对象提供新的值;

  • LocalString.current

使用current API获取由最近的 CompositionLocalProvider 提供的值;

使用CompositionLocal后,我们可以明显发现BlueBox和GreenBox无需被动添加text参数了,在可组合函数的顶层提供了相应的值后,直接在RedBox中使用LocalString.current就可以得到需要的值。

虽然CompositionLocal很好用,但是Compose不建议我们过度使用,具体的适用情况请参考官网:https://developer.android.google.cn/jetpack/compose/compositionlocal#deciding 。

7、Recomposition

重组,说的再直白一点就是视图内容的更新,在View体系中我们需要调用相关的Setter命令式的手动更新视图的显示,而Compose是声明式的,如果需要更新内容显示那么就需要重组。但是这不需要我们做任何事情,系统会根据需要使用新的数据重新调用可组合函数绘制出视图。

先来看如下示例,Text1是需要由timestamp的状态驱动,Text2直接固定了参数是当前的时间戳,然后点击Text3后更改timestamp的状态值,那么这种情况下大家觉得数据显示是怎样的呢?:

@Composable
fun RecompositionSample() {

    val timestamp = remember {
        mutableStateOf(0L)
    }

    Column {
        Text(text = "Text1: ${timestamp.value}")

        Text(text = "Text2: ${System.currentTimeMillis()}")

        Text(text = "Text3: Click To Update Time", modifier = Modifier.clickable {
            timestamp.value = System.currentTimeMillis()
        })
    }
}

直接看如下结果,看着好像有点不对劲呢?为啥Text2的时间戳也会随我们点击更新?Compose说好的智能重组呢?

Recompose1.gif

带着疑问我们再将上述示例代码稍微做下变动,给Text2单独“封装”了一层,整体如下所示:

@Composable
fun RecompositionSample() {

    val timestamp = remember {
        mutableStateOf(0L)
    }

    Column {
        Text(text = "Text1: ${timestamp.value}")

        TextWrapper()

        Text(text = "Text3: Click To Update Time", modifier = Modifier.clickable {
            timestamp.value = System.currentTimeMillis()
        })
    }
}

@Composable
fun TextWrapper() {
    Text(text = "Text2: ${System.currentTimeMillis()}")
}

此时再运行结果如下,Text2的时间戳居然不会变化了:

海外直播、聊天交友APP的开发及上架GooglePlay体验【Compose版】_第8张图片

这可能就是让大家迷惑的地方了,Box、Column、Row等是用了inline标记,他们都是内联函数(内联函数会将其中的函数体复制到调用处),会共享调用方范围,所以RecompositionSample中的所有直接组件都会进行重组。而当其中无关的Text2被“封装”后,相当于做了一层隔离,被封装的Text不受timestamp状态的影响,便不再参与重组。倘若我们给TextWrapper再加上inline标记,那么运行结果后,其时间戳依旧会进行变化。

关于重组原理这块研究太浅,没有太多东西能分享出来,还望大家见谅。不过通过上面的例子,我们在开发中的时候应该要注意的就是:复杂的页面万万不可一把梭,按功能、按业务多抽离出相应的非inline可组合函数,以达到复用和隔离的效果。

8、Side-effects

View是有生命周期的,例如View的onAttachedToWindow() 及onDetachedFromWindow() 等,那么在Compose中有这些内容吗?有!我们暂且以生命周期的方式去理解Compose的副作用!

假如有这么一种场景,每次点击按钮使得计数器累加,当计数器在2-5的时候我们添加一个文本显示当前计数器的数字,否则移除文本,代码如下所示:

@Composable
fun SideEffectsSample() {

    val tag = "SideEffectsSample"

    val count = remember {
        mutableStateOf(0L)
    }

    Column {

        Button(onClick = { count.value++ }) {
            Text(text = "Click To Update")
        }

        if (count.value in 2..5) {

            //用于显示计数器数字的文本
            Text(text = "Count :${count.value}")

            LaunchedEffect(key1 = true, block = {
                Log.e(tag, "LaunchedEffect: ${count.value}")
            })

            SideEffect {
                Log.e(tag, "SideEffect: ${count.value}")
            }

            DisposableEffect(key1 = Unit, effect = {
                Log.e(tag, "DisposableEffect: ${count.value}")
                onDispose {
                    Log.e(tag, "DisposableEffect onDispose: ${count.value}")
                }
            })
        }
    }
}

直接看下日志的输出结果吧,是不是符合你的预期呢:

SideEffectsSample: DisposableEffect: 2
SideEffectsSample: SideEffect: 2
SideEffectsSample: LaunchedEffect: 2
SideEffectsSample: SideEffect: 3
SideEffectsSample: SideEffect: 4
SideEffectsSample: SideEffect: 5
SideEffectsSample: DisposableEffect onDispose: 6

当计数器达到2的时候,文本显示,此时我们所添加的三个效应全部会执行。当计数器累加到3、4、5的时候只有SideEffect效应执行。当计数器累加到6的时候,文本消失,DisposableEffect回调onDispose。所以大致可理解为:

  • LaunchedEffect - 在可组合函数首次进入组合时执行
  • SideEffect - 在可组合函数每次进行重组时都会执行
  • DisposableEffect- 在可组合函数首次进入组合时执行,并在可组合函数退出时回调onDispose

当然了还要注意一点,LaunchedEffect和DisposableEffect都需要一个key,在上文示例中我们使用的是true和Unit,当使用这种常量的时候,这些副作用会遵循当前调用点的生命周期。如果使用其他可变类型的数据,那么这些副作用会根据数据是否变更而进行重启,不明白的话就试试将key赋值为count.value,然后再看看日志的输出结果吧。

明白了的话再去官网查看生命周期副作用的文章吧,相信大家会更有收获,我反正是每次看都觉得好像又多学到了点啥,具体是啥又不太清楚。

9、Dialog、DropdownMenu

Compose中也为我们提供了Dialog的组合函数,但是仔细想一下,我们在很多情况下可能是某一事件触发需要显示Dialog,比如收到一条紧急通知消息,我需要在App的任意页面上进行弹窗展示,如果使用Compose这种状态驱动的,就有点不是那么好搞了,而且其必须要在Compose的可组合函数内使用,太受局限了。

在原来的View系统下我们一般是这么做,收到通知消息后,获取顶层的Activity,然后进行弹窗处理。在Compose中这点我感觉也没必要变,Dialog还是使用原来的DialogFragment,只不过是DialogFragment的视图内容使用Compose去实现就可以了。使用DialogFragment还有好处就是我们可以使用ViewModel,这点我们在下文ViewModel中说下相关的问题。

而DropdownMenu则是跟页面关联比较大的,所以在使用上遵循Compose的方式即可。

Android Studio篇

1、文件夹命名

Snipaste_2021-11-17_17-53-36.png

本来有一些UseCase类打算统一归档到某文件夹(包)下,于是打算将文件夹命名为【case】,如上所示,则文件夹显示出来的图标跟其他正常的文件夹图标不一致,此时放置在【case】文件夹中的类在使用时可能就会遇到各种各样的问题,如果UseCase类涉及到了Hilt,那么Hilt生成相关文件时也会遇到失败。

改完包名后恍然大悟,case是java中的关键字啊,kotlin中when用的多了,switch case居然快忘却了。献丑了,博大家一笑。

2、不跨长城非好汉

在接入Firebase Crashlytics SDK时,打debug包正常,但是打Release包却会报出如下错误:

What went wrong:
Execution failed for task ‘:app:uploadCrashlyticsMappingFileXXXRelease’.
org.apache.http.conn.HttpHostConnectException: Connect to firebasecrashlyticssymbols.googleapis.com:443 [firebasecrashlyticssymbols.googleapis.com/172.xxx.xxx.xxx] failed: Connection timed out: connect

问题就出在uploadCrashlyticsMappingFileXXXRelease这个task,Firebase Crashlytics SDK需要将项目混淆后的Mapping等文件上传到Google的服务器,这一步需要跟Google服务器打交道啊,你想想,你看看,你琢磨琢磨。所以你必须要让AS跨过长城。

首先是梯子,这个就比较多了,大家私下交流好了。然后一般梯子都有代理的端口的,记下来,在gradle.properties文件中添加如下配置,这样的话问题基本也就迎刃而解了:

# 代理地址,本机的话127.0.0.1
systemProp.https.proxyHost=xxx.xxx.xxx.xxx

# 代理端口,看你梯子设置的端口
systemProp.https.proxyPort=xxxx

还有一种暴力解法,就是直接关闭这个Task,这在打开发包或者测试包的时候可以临时用下,但是千万别用在生产环境:

gradle.taskGraph.whenReady {
    tasks.each { task ->
        if (task.name.contains("uploadCrashlyticsMappingFile")) {
            task.enabled = false
        }
    }
}

具体内容可以参考我的原文:Firebase Crashlytics集成再踩坑

架构篇

1、MVVM、MVI

先说到原先的MVP架构,P层是持有V层的,那么就需要关注P层的生命周期,并且需要手动调用View进行数据的更新。而到了Compose中,Compose是响应式的、是状态驱动的,所以无需MVP那种命令式更新了,天生适合MVVM或者MVI的架构。

而在MVVM和MVI中我们又该如何取舍呢,使用在Compose中其实两者好像没有没有太大的区别,MVI更加强调的是单向数据流动,是一种响应式和流式的处理思想。目前整体实践下来也就一句话的总结:业务简单就MVVM,业务复杂就MVI

何出此言呢?一起来实践下,VM层的实现我们就使用Jetpack ViewModel,MVVM和MVI的区别也就体现在了V层如何与VM层进行交互。我们通过Compose实现单向数据流动,状态(数据)向下流动,事件向上流动。然后将状态存储在ViewModel中,Compose中数据的值则从ViewModel中获取。最简单的情况可能就是这样:

  • MainViewModel:
class MainViewModel : ViewModel() {
    val text = mutableStateOf("default")
}
  • MainActivity:
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val vm = ViewModelProvider(this).get(MainViewModel::class.java)

        setContent {
            Text(text = vm.text.value)
        }
    }
}

然而当遇到点击事件的时候就多了些方法。三两个还好,我们直接在ViewModel中暴露出相应的方法,Compose中则将事件层层提升,在顶层的组合函数中调用VM的方法即可。然而当事件越来越多时,这种方式就明显力不从心了,整体比较乱。

所以在复杂的业务中我们需要将相关事件分组或者集中进行整理,比如有几个通话事件start、calling、end、timeout等,我们需要使用什么样的方法将这些事件综合在一起呢?由于Kotlin的强大和便利性,我们有如下几种方法:

  • data class

这种方式最简单也最为直接了,而且可以直接从Compose函数顶层传递到底层(如果偷懒不想将事件层层提升上来,那么将事件处理的对象层层传递下去也不是不可):

data class CallAction1(
    val start: (callId: Long, callName: String) -> Unit = { _: Long, _: String -> },
    val calling: () -> Unit = {},
    val end: () -> Unit = {},
    val timeout: () -> Unit = {},
)

//ViewModel中进行实例化,可直接暴漏给V层
val callAction1 = CallAction1(
    start = { callId, callName ->
            Log.e(tag, "callAction1 start: $callId $callName")
    }
)

//在V层直接调用
vm.callAction1.start(12, "Hello")
  • sealed class

这种方式稍微麻烦一点,但是可以使用参数名,不过V层(Compose)仍旧需要将事件层层提升上来:

sealed class CallAction2 {
    class Start(val callId: Long, val callName: String) : CallAction2()
    object Calling : CallAction2()
    object End : CallAction2()
    object Timeout : CallAction2()
}

//在ViewModel中提供方法,暴漏给V层
fun processCallAction2(callAction2: CallAction2) {
    when (callAction2) {
        is CallAction2.Start -> {
            Log.e(tag, "callAction2 start: ${callAction2.callId} ${callAction2.callName}")
        }
        else -> {

        }
    }
}

//在V层调用
vm.processCallAction2(
    callAction2 = CallAction2.Start(callId = 12, callName = "Hello")
)
  • enum class

这种方式比较适合不带参数的情况了,不如上述两种方式灵活,大家择需使用吧。

2、ViewModel

这里说一种在DialogFragment中使用ViewModel的场景。DialogViewModel示例如下:

class DialogViewModel : ViewModel() {

    private val tag = DialogViewModel::class.java.simpleName

    fun test() {
        Log.e(tag, "invoke test")
        viewModelScope.launch {
            Log.e(tag, "invoke launch")
        }
    }
}

在DialogFragment中我们可以使用fragment-ktx包中的 viewModels() 扩展函数来实例化DialogViewModel,并通过按钮点击执行test()方法,如下:

class MyDialog : DialogFragment() {

    private val vm by viewModels<DialogViewModel>()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return ComposeView(requireContext()).apply {
            setContent {
                Button(onClick = { vm.test() }) {
                    Text(text = "Click")
                }
            }
        }
    }
}

然后在Activity中,我们可以有如下两种方式显示Dialog:

  • 每次创建新的实例
MyDialog().showNow(supportFragmentManager,"dialog")
  • 使用同一个实例
private var myDialog: MyDialog? = null

if (myDialog == null) {
    myDialog = MyDialog()
}

myDialog?.showNow(supportFragmentManager, "dialog")

当使用第一种方式的时候,显示弹窗,点击按钮后打印日志一切正常。
但是当我们使用第二种方式的时候,第一次显示弹窗,点击按钮一切正常,但是关闭弹窗,第二次显示弹窗然后点击按钮的时候,打印信息却只有:

E/DialogViewModel: invoke test

协程中的日志却不会打印出来,这是怎么回事呢?我们复写下DialogViewModel的onClear()方法,并复写下MyDialog的onDestroy()方法,然后打印下日志:

//DialogViewModel
override fun onCleared() {
    super.onCleared()
    Log.e("DialogViewModel", "onCleared")
}

//MyDialog
override fun onDestroyView() {
    super.onDestroyView()
    Log.e("MyDialog", "onDestroyView")
}

override fun onDestroy() {
    super.onDestroy()
    Log.e("MyDialog", "onDestroy")
}

这个时候我们再使用第二种方法进行测试,第一次显示出弹窗执行了test方法后,关闭弹窗,日志打印如下:

E/MyDialog: onDestroyView
E/DialogViewModel: onCleared
E/MyDialog: onDestroy

此时我们应该恍然大悟了,因为使用viewModels()扩展函数创建的ViewModel使用的是Fragment的ViewModelStore,所以DialogFragment onDestroy时,ViewModel也会进行clear(),然后使用viewModelScope创建的协程就会取消,从而导致协程内的代码不会被执行。

如果我们需要使用第二种方法,那就要保证ViewModelStore是Activity的ViewModelStore,这样才可以保证你的协程是随着Activity而不是DialogFragment。
所以绕这么一大圈也就是为了点名fragment-ktx中的另一个扩展函数:activityViewModels()

3、Hilt

具体使用Hilt的方法不是本文的重点,这里主要是说下遇到的问题及解决办法。

插曲:原来在开发的时候集成Hilt很顺利,但是编写本文的时候又重新集成了一遍,运行却总是报错,内容如下,网内外全部搜索、并尝试了一遍后还是无解。由于官网上集成提供的示例是2.28-alpha版的,所以打算换个版本集成看下效果,于是在仓库 【https://mvnrepository.com/artifact/com.google.dagger/hilt-android】 中查询到目前最新版本是2.40.5,果断换过后一切问题都解决了。

Execution failed for task ‘:app:kaptDebugKotlin’.
》 A failure occurred while executing org.jetbrains.kotlin.gradle.internal.KaptWithoutKotlincTask$KaptExecutionWorkAction
》 java.lang.reflect.InvocationTargetException (no error message)

1、AndroidEntryPoint

当每次给新的Activity添加上@AndroidEntryPoint注解后,在编译期间总会莫名其妙的报错无法运行,所以每次添加完注解后建议先Clean Project一下。

2、ViewModel

当ViewModel有参数的时候,在2.28-alpha版本可以使用hilt-common包中的 @ViewModelInject 注解,直接使用在ViewModel的构造函数上,如下所示:

class MainViewModel @ViewModelInject constructor(
    private val sampleBean: SampleBean
) : ViewModel() {}

但是本文示例的时候,使用2.40.5版本,ViewModelInject注解已经废弃,需要换用 @HiltViewModel 注解在ViewModel类上,同时还需要**@Inject**注解在构造函数上,稍微复杂了一点,如下所示:

@HiltViewModel
class MainViewModel @Inject constructor(
    private val sampleBean: SampleBean
) : ViewModel() {}

在使用ViewModel时则依旧可以借助 viewModels(), activityViewModels() 等扩展函数来实例化。

3、在不支持的类中注入

Hilt支持常见的Android类注入,但是有些时候可能我们需要在非Android类来中进行注入,比如在object类中进行注入我们如下的SampleBean:

class SampleBean @Inject constructor() {
    fun print() {
        Log.e("SampleBean", "invoke print")
    }
}

如果直接在SampleManager单例类中使用@Inject进行注入,如下:

object SampleManager {

    @Inject
    lateinit var sampleBean: SampleBean

}

那么编译的时候就会直接报错:

Dagger does not support injection into Kotlin objects
public final class SampleManager {
^

所以针对这种情况,有如下两种处理方式:

  • 将SampleManager改造为Hilt的单例模式
@Singleton
class SampleManager @Inject constructor(
	private val sampleBean: SampleBean
) {}
  • 使用Hilt提供的 **@EntryPoint **和 @EntryPointAccessors 注解

首先我们需要为SampleBean创建一个EntryPoint(入口点,切入点),如下所示一个接口即可,接口使用@EntryPoint来注解,同时@InstallIn(SingletonComponent::class)注解表示将SampleBean以单例的形式提供,当然你也可以选择其他形式:

@EntryPoint
@InstallIn(SingletonComponent::class)
interface SampleBeanEntryPoint {
    fun provideSampleBean(): SampleBean
}

有了SampleBean实例的EntryPoint 后,我们就需要从EntryPointAccessors 中获取到切入点,从而获取到SampleBean的示例,代码如下:

object SampleManager {

    fun print(context: Context) {

        //从EntryPointAccessors获取到SampleBean的EntryPoint
        val sampleBeanEntryPoint = EntryPointAccessors.fromApplication(
            context.applicationContext,
            SampleBeanEntryPoint::class.java
        )

        //从SampleBean的EntryPoint获取到SampleBean的实例
        val sampleBean = sampleBeanEntryPoint.provideSampleBean()

        sampleBean.print()
    }
}

上述代码中,EntryPointAccessors调用了fromApplication()来表示获取单例对象内容,其他还支持如下内容,大家择需获取:

  • fromActivity()
  • fromFragment()
  • fromView()

4、Room


这里说两个情况大家注意一下就好:

1、查询结果使用Flow的时候,方法无需再使用suspend标记;

如下我们使用suspend对方法进行了标记:

@Query("SELECT * FROM gift WHERE type = :type ORDER BY order_num DESC")
suspend fun queryGiftList(
    type: Long
): Flow<List<GiftEntity>>

在编译后则会报错如下:

Not sure how to convert a Cursor to this method’s return type (kotlinx.coroutines.flow.Flow).
public abstract java.lang.Object queryGiftList(long type, @org.jetbrains.annotations.NotNull()
^

2、当查询单个对象的时候注意返回结果要可空;

@Query("SELECT * FROM message WHERE (message_id = :messageId)")
fun queryMessage(messageId: Long): Flow<MessageEntity?>

Kotlin篇

1、Kotlin Android Extensions

在升级kotlin 1.5.31后 kotlin-android-extensions插件已废弃,该插件中有处理序列化的内容,所以之前用到 @Parcelize 注解的都需要做如下替换:

在gradle脚本中换用新插件"kotlin-parcelize":

apply plugin: 'kotlin-parcelize'

然后将原来注解使用的包名作一下替换:

import kotlinx.android.parcel.Parcelize ----替换为----> import kotlinx.parcelize.Parcelize

2、LiveData、Flow【StateFlow、ShareFlow】

问题主要在于LiveData的生命周期感知能力,如果直接将LiveData用做事件,那么则不合理。
场景:如果一个页面处于后台,也就是Stop状态,那么LiveData作为事件则无法立即通知到Activity并执行。
考虑使用ShareFlow等,搭配lifecycleScope、repeatOnLifecycle等作为事件,注意repeatOnLifecycle API 是在"lifecycle-runtime-ktx:2.4.0"版本提供的。示例代码如下:

val finishEvent = MutableSharedFlow<Int>()

lifecycleScope.launch {
    lifecycle.repeatOnLifecycle(state = Lifecycle.State.CREATED) {
        finishEvent.collect {

        }
    }
}

另一种方式则如下:

val finishEvent = MutableSharedFlow<Int>()

lifecycleScope.launch {
    finishEvent.flowWithLifecycle(
        lifecycle = lifecycle,
        minActiveState = Lifecycle.State.CREATED
    ).collect {

    }
}

点进去flowWithLifecycle()扩展函数可以看到其仍然使用了repeatOnLifecycle()函数,关于repeatOnLifecycle API请参考:

  • 设计 repeatOnLifecycle API 背后的故事
  • 使用更为安全的方式收集 Android UI 数据流

3、Json的序列化和反序列化

关于Json的解析,在Java中可能我们大部分都是使用的Gson来解析,但是切换到Kotlin后如果依旧使用Gson来解析的话,由于Kotlin空安全的特性,在使用Gson时稍不规范那么就可能会遇到崩溃问题。
具体请参考我的博客【Kotlin中Json的序列化与反序列化 – Gson、Moshi】

三方SDK篇

ARouter

Kotlin中添加混淆后无法获取到传递来的参数数据,除了需要使用@JvmField注解外,还需要添加@Keep注解,如下所示:

@Keep
@JvmField
@Autowired(name = "type")
var type: Int = 0

Billing

如果需要使用Google支付,那么请一定注意以下几点:

  • Google账户中设置的地区不可以是中国,如果是中国,则你的账号无法进行购买
  • 梯子的节点也很重要,日本节点最有效,其他地区的节点可能导致无法进行购买,建议多进行尝试
  • 如果还有问题就将GooglePlay商店缓存及数据清空,然后重新打开

否则的话你在开发中会遇到如下各种各样的失败提示:

  • Google Play In-app Billing API version is less than 3
  • An internal error occurred.
  • Purchase is in an invalid state.

第三种情况是当我们将测试账号设置为 “测试卡,一律拒绝” 的方式后,那么则会出现该错误。如下图:

海外直播、聊天交友APP的开发及上架GooglePlay体验【Compose版】_第9张图片

海外直播、聊天交友APP的开发及上架GooglePlay体验【Compose版】_第10张图片

Facebook

集成Facebook登录时需要生成密钥散列,建议直接使用如下代码获取:

private fun facebookKeyHash() {
    try {
        val info = packageManager.getPackageInfo(
            application.packageName,
            PackageManager.GET_SIGNATURES
        )
        for (signature in info.signatures) {
            val md = MessageDigest.getInstance("SHA")
            md.update(signature.toByteArray())
            Log.d(
                "KeyHash",
                android.util.Base64.encodeToString(md.digest(), android.util.Base64.DEFAULT)
            )
        }
    } catch (e: Error) {
        e.printStackTrace()
    }
}

如果你需要使用openssl工具以命令行的方式获取的话,那么请一定注意版本问题,在Windows上我们需要使用openssl-0.9.8e_X64而不是openssl-0.9.8k_X64.的版本。否则使用Facebook登录就总会收到类似 "密钥散列不匹配 "的错误。

Google Play

目前在Google Play Console发布新版的话只能使用bundle包的方式了。而且Google会自动帮我们签名好的应用重新签名,在如下 设置->应用完整性 可以看到,Play App Signing会自动启用,这时候如果集成了其他三方SDK需要应用签名信息或者秘钥散列信息的话,这时候从Google Play商店下载下来的包就会无法正常使用。比如上文的Facebook登录,还有如Google登录等。

海外直播、聊天交友APP的开发及上架GooglePlay体验【Compose版】_第11张图片

这时候我们需要将Google的签名信息也配置到其他三方SDK中,例如在Facebook中,需要的是秘钥散列,直接在Google Play商店搜索KeyHash,安装后选择你从Google Play下载的自己的应用,然后就可以获取到密钥散列了。Facebook后台单个项目是允许配置多个密钥散列的,所以直接将其配置到你的项目中即可。

关于Google登录的话,需要打开Google Cloud Plateform,选择你的项目,然后在 API和服务 -> 凭据 中,创建新的凭据,选择 OAuth 2.0 客户端 ID ,然后将Google Play Console中Google给我们签名的证书指纹添加进去即可。

当然了也可以请求升级密钥,但是这就需要重新发包处理了,过程比较麻烦,可以参考网上其他文章,这里不再赘述了。上述方案是最简单直接的,改完即可生效。

总结

我可能是Google的脑残粉了,很多新技术我都会立刻尝鲜。DataBinding刚出生,尝了,感觉不够香,也可能是不适合我或者我功力不够,直接放弃了。ViewBinding刚出生,尝了,香,于是立刻将手中基于ButterKnife的老项目改造,并记录了博客《是时候拥抱ViewBinding了!!!》,分享了自己在项目中的探索经验。从19年初识Compose到21年Google正式发布Release版,我又迫不及待的立刻就拥抱了Compose。经过了前面半年的学习和近3个月的开发测试,我说不清楚是Kotlin带给我的喜悦,还是Compose带给我的激动,这3个月的开发体验和我之前3年的体验都大不相同,这一套的Jetpack搭配Kotlin,简直让我更加乐意去开发Android。同时也让我和其他大前端有了一定的互怼吹水能力,什么双向绑定、数据驱动、响应式编程、单向数据流,我们也有了,我也会了。然而这种追新常常也伴随着代价,API变更、AS升级、AGP升级等等,每一次都可能让你推倒重来,甚至耗费几天毫无进展。可是呀,生命就在于折腾吧!!!折腾折腾就可能会发现,原来这道题还有更简单的解法。

诚惶诚恐,文章越写越长,实在想把开发中遇到的问题及解决方案都尽善尽美的描述出来,然而因为个人能力原因很多东西还没有深入探究原理,也不清楚是否会有误导大家的地方,文章如有纰漏还请各位不吝赐教。

参考及文章推荐

Compose架构相关

关于架构可以参考如下文章,尤其是游戏,可以从代码中看出MVI架构的优势。

  • Jetpack Compose 架构如何选? MVP, MVVM, MVI @fundroid
  • 爷童回!Compose + MVI 打造经典版的俄罗斯方块 @fundroid
  • 用Android Jetpack Compose重写微信经典飞机大战游戏 @Annon

Compose重组相关

关于重组方面以及原理等方面的文章可以查阅RugerMc以及fundroid的相关文章,同时也把大佬的 Jetpack Compose中文手册项目 推荐给大家。

  • 手把手带你走一遍Compose重组流程 @RugerMc
  • Compose 的重组会影响性能吗?聊一聊 recomposition scope @fundroid
  • Jetpack Compose Runtime : 声明式 UI 的基础 @fundroid
  • 深入详解 Jetpack Compose | 实现原理 @Android_开发者

Compose自定义相关

自定义相关的内容可以参考如下文章,路很长OoO大佬的自定义功力很深。

  • Jetpack-Compose 水墨画效果 @路很长OoO
  • Jetpack-Compose - 自定义绘制 @路很长OoO


最后是自己当初水水的文章,在Compose alpha07版本写的,很多API已经更改或者废弃了,所以大家不用过多研究,接下来应该会着手进行更新及添加实战示例了

  • 是时候学习Jetpack Compose了!!! Compose文章汇总篇
  • Compose搭档 — ViewModel、LiveData
  • Compose搭档 — Flow、Room

哦哦,对了,还有官网啊,也别忘了多去官网瞧瞧。

你可能感兴趣的:(Jetpack-Compose,Android,Studio,kotlin,android,java,Compose,Jetpack)