海外直播聊天交友APP的开发及上架GooglePlay体验Compose版
Posted 乐翁龙
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了海外直播聊天交友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、LiveData、ViewBinding、ConstraintLayout
- 架构
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 =
)
它渲染出来的样式如下:
如你所见,这个TextField实在是没办法满足大部分UI的需求,而且它的可定制性几乎为0。当我们更改其Shape为圆角属性并强行设置TextField的高度时,它的渲染效果居然如下图所示:
点进去TextField的源码可以看到只有TextStyle、TextFieldColors、Shape等可供我们自行实现。按着源码一路找下去,发现在TextFieldLayout下已经强行将TextField的最低高度限制为了 MinHeight = 56.dp。
所以,综上所述,针对TextField我们强烈建议使用BasicTextField进行统一的自定义以满足项目UI的需求。至于如何自定义,网上文章已经很多了,这里不再赘述。
OK,说完了样式问题,接下来还有键盘的问题。
当我们做聊天页面的时候,输入框是在屏幕底部的,此时弹出键盘就会遇到问题了,如下所示:
键盘会对齐到输入框中文字的底部,我们肯定不想要这样的效果,正常起码应该是将整个圆角矩形显示完全。此时在清单文件中设置软键盘的模式为 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() 会清空焦点,键盘则会同步隐藏,效果如下所示:
2、LazyVerticalGrid And Paging
除了LazyRow和LazyColumn外,Compose还提供了LazyVerticalGrid可以帮助我们实现表格列表,其实点进去源码查看其最终还是使用了LazyColumn进行了实现。所以使用方式上基本类同LazyColumn,搭配Accompanist的 Swipe to Refresh 依赖库也是没有问题的。
相信在XML的时代,大家肯定被RecyclerView、Adapter支配过,再加上下拉刷新,上拉加载,代码是牵一发动全身。但是在Compose中,开发这种情形的话代码量骤减,效率暴增!!!下面我们通过Paging依赖库分别简单示例从本地和远程获取分页数据:
- 本地分页数据
我们以Room中存储的聊天消息列表为例,Room直接支持获取到DataSource.Factory<Int, T>类型的分页数据,如下所示:
@Query("SELECT * FROM message ORDER BY timestamp DESC")
fun queryMessageList(): DataSource.Factory<Int, MessageEntity>
然后我们将使用 **Pager<Key : Any, Value : Any> **类其处理成返回Flow<PagingData>类型的数据:
protected fun <T : Any> pageDataLocal(
dataSourceFactory: DataSource.Factory<Int, T>
): Flow<PagingData<T>> = Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = dataSourceFactory.asPagingSourceFactory()
).flow
然后在Compose中将Flow<PagingData>类型的数据转换为LazyPagingItems类型给LazyColumn、LazyRow或者LazyVerticalGrid使用,这些在paging-compose依赖中有提供,整个基本的聊天消息列表可能就如下这么简单:
val messageList = vm.messageList.collectAsLazyPagingItems()
LazyColumn
items(messageList)
//your item content
- 远程分页数据
当然了,还有很多列表数据都是需要请求服务器的,那么实现这种就稍微复杂了一点。同样的我们需要先获取到Flow<PagingData>类型的数据,但是不像Room那样我们可以直接拿到DataSource.Factory<Int, T>的数据,这里我们得通过继承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<Key : Any, Value : Any> **类其处理成返回Flow<PagingData>类型的数据:
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,
)
显示结果如下左图所示,更改状态颜色为黑色,图标为白色后,显示结果如下右图所示:
如果要控制图片沉浸到状态栏呢?重点在这里 WindowCompat.setDecorFitsSystemWindows(window, false),这样我们就可以让内容区域延伸到状态栏,然后我们给状态栏设置透明色,并使用白色图标,那么显示结果就如下所示了。
但是还有一个问题,就是标题区域也延伸到了状态栏,我们的需求是图片背景延伸到状态栏,但是标题区域需要在状态栏下方。
还是借助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就会显示到了状态栏的下面,如下图所示:
当然,处理这种情况的话还有一个方法,使用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 注解来标记就可以实现一个可组合函数到视图的预览,单纯的预览没啥可多说的,我们也结合下主题来多讲点。
首先是DarkTheme和LightTheme,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文件,这其中就定义了DarkTheme和LightTheme的相关颜色,我们将上述两个主题中的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的一套主题的规范,无规范则寸步难行啊。
关于无法预览的异常情况说明:
在原来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参数层层传递,从蓝色,传递到绿色,再到红色。
伪代码如下所示(虽然上述视图可以直接在一个可组合函数中完成,但是为了说明实际开发业务中的一些复杂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说好的智能重组呢?
带着疑问我们再将上述示例代码稍微做下变动,给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的时间戳居然不会变化了:
这可能就是让大家迷惑的地方了,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 = 以上是关于海外直播聊天交友APP的开发及上架GooglePlay体验Compose版的主要内容,如果未能解决你的问题,请参考以下文章
海外直播聊天交友APP的开发及上架GooglePlay体验Compose版