Loading...
墨滴

vitaviva

2021/12/26  阅读:46  主题:前端之巅同款

Compose版直播交由 APP 上架 GooglePlay

作者:乐翁龙
链接:https://juejin.cn/post/7042181171706331150

前言

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
  • 架构: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() 会清空焦点,键盘则会同步隐藏,效果如下所示:

TODO

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 == 1null 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 (pageNumberInt) -> 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.

重组,说的再直白一点就是视图内容的更新,在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 说好的智能重组呢?

TODO

带着疑问我们再将上述示例代码稍微做下变动,给 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 的时间戳居然不会变化了:

TODO

这可能就是让大家迷惑的地方了,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.文件夹命名

本来有一些 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")
}
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: Longval 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.AndroidWEntryPoint

当每次给新的 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<? extends java.util.List>). 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() 函数

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

关于Json的解析,在Java中可能我们大部分都是使用的Gson来解析,但是切换到 Kotlin 后如果依旧使用 Gson 来解析的话,由于Kotlin空安全的特性,在使用Gson时稍不规范那么就可能会遇到崩溃问题。

三方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.

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

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 登录就总会收到类似 "密钥散列不匹配 "的错误。

Googgle Play

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

这时候我们需要将 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升级等等,每一次都可能让你推倒重来,甚至耗费几天毫无进展。可是呀,生命就在于折腾吧!!!折腾折腾就可能会发现,原来这道题还有更简单的解法。

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

vitaviva

2021/12/26  阅读:46  主题:前端之巅同款

作者介绍

vitaviva