缘起
最近群友指出了 scheme
组件使用的一些不完美和可改进点,主要有以下几个:
1.DeepLink
该如何支持?
2.期望使用时可以获取结构化的数据(data class
),避免从 NavBackStackEntry
去 getString
、getInt
之类的。
3.期望有更好的转场动画支持。
对于 DeepLink
而言,因为 scheme
本来就是 uri
的结构,所以我建议的方案是用一个透明的 Activity
做中转,把 protocol
和 host
部分一下,就是可以用来接入 scheme
框架了,所以本文不做过多分析。
所以最新更新的 0.8.0
主要是为了解决传参结构化和转场动画问题。
结构化传参与解析
目前 scheme
提供的传参方式主要是 Bundle
式的原始方案:在传参需要使用 schemeBuilder.arg(name, value)
的形式链式拼接,而使用时则需要从 NavBackStackEntry
的 arguments
中去一个个的取出来,所以这里存在 name
的管理,而且你还需要记住不同的 name
对应的 value
的类型
@ComposeScheme( action = SchemeConst.ACTION_HOME, alternativeHosts = [HomeActivity::class] ) @Composable fun HomePage(navBackStackEntry: NavBackStackEntry) { val a = navBackStackEntry.arguments?.getString("nameA") val b = navBackStackEntry.arguments?.getInt("nameB") }
而结构化传参则期望我传递给 Composable
函数的就是结构化的数据
@ComposeScheme( action = "action", alternativeHosts = [MainActivity::class] ) @Composable fun SchemeModelPage(arg: DataArg){ }
因为我们参数会以 url query
的形式传递,实际上我们就需要实现一个 Encode/Decode
的过程。
要实现这个方案,我们有两种选择:
1.反射:Encode
通过反射得到 class
下的所有字段名和值,来拼接字符串。Decode
通过将字符串解析成 Map
, 再反射赋值给 class
。
2.代码生成:通过 ksp
为每个 class
生成相应的 Encode/Decode
方法实现
为了性能考虑,一般我们会选择代码生成的方案,不过我们并不需要从零开始去设计一套方案,因为我们已经有了强大的 kotlin-serialization
。 因为这本身也是一个序列化反序列化的过程,只不过我们这里只是序列化成了 url query
的形式。大家一般都是用了 kotlin-serialization-json
来做 json
的序列化,其实大家不知道是它还可以被序列化成 protobuf
、cbor
等形式,抽象是做得相当好的了。
使用
首先,定义参数类
// 只支持 bool,int,long,float,string 这几个类型 // 可以享受 Kotlin 的默认值 @Serializable data class DataArg( val i: Int = 3, val l: Long = 4, val b: Boolean = true, val str: String = "xixi" )
scheme
构建可以从参数类中构建
val arg = DataArg(str = "hehe") // 通过传递给 SchemeBuilder 的 model 来构建 scheme val scheme = schemeBuilder.model(arg).toString()
然后就可以在 Composable
方法上直接使用了
@ComposeScheme( action = "action", alternativeHosts = [MainActivity::class] ) @Composable fun SchemeModelPage(arg: DataArg){ // 直接将参数类传递给 Composable 函数就行 }
如果你需要使用到 NavBackStackEntry
, 那也可以写到方法里
@ComposeScheme( action = "action", alternativeHosts = [MainActivity::class] ) @Composable fun SchemeModelPage(navBackStackEntry: NavBackStackEntry, arg: DataArg){ // 注意顺序不能变更 }
当然你可以不使用这一特性,旧版本的工作方式依旧能正常工作。
异常处理
由于引入了序列化与反序列化,就有一些更多不可控的因素。例如使用了 scheme
不支持的类型,如列表等。还有反序列化失败等。
如果有异常那就崩溃,那体验就不好了。 如果把异常全都吞掉,那开发查问题就太难了。所以这里关键倚靠的是 EmoConfig.debug
的值了:
- 如果值为
true
, 那就会直接抛出异常,直接crash
掉 - 如果值为
false
, 那就会吞掉异常,具体表现为:
- 如果是从参数类中构建
scheme
时失败了,那这个scheme
不会触发跳转。 - 如果从
scheme
中解析参数类失败了,那就视Composable
函数签名而定了: 如果Composable
函数指定参数可空 即声明为fun SchemeModelPage(arg: DataArg?)
,则函数获得的实参为null
,交给开发者自己去处理这种情况;如果声明了不可空,则Composable
函数不会被调用,用户侧可能就看到白屏了。
动画
scheme
框架底层依赖的是 accompanist
的 Navigation
库,其本身就有提供高度自定义化的动画支持。其函数签名为:
public fun NavGraphBuilder.composable( route: String, arguments: List<NamedNavArgument> = emptyList(), deepLinks: List<NavDeepLink> = emptyList(), enterTransition: (AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition?)? = null, exitTransition: (AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition?)? = null, popEnterTransition: ( AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition? )? = enterTransition, popExitTransition: ( AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition? )? = exitTransition, content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit )
其就包括了 enter
、exit
、popEnter
、popExit
四个动作场景的动画,在旧版本,虽然有提供动画自定义,但是将原本的功能给阉割了部分,而新版本虽然使用上不算完美,但保留了其全部自定义的能力。
基础知识
如果我们使用过 Fragment
,那么你肯定对动画的这四个动作很熟悉。但是,两者的名字相同,但代表的意义并不一致。
Fragment
启动一个新的界面,是开启了一个事务,然后在这个事务中,规定新旧界面的动画, 假设有界面 A
和 B
:
- 从
A
切换到B
, 对B
应用enter
, 对A
应用exit
- 从
B
返回到A
, 对B
应用popExit
, 对A
应用popEnter
简单记忆就是 1,4
参数应用新界面, 2,3
参数应用旧界面。
但是到了 Compose
情况就不一样了,Compose
是声明式,用状态描述一切,composable
是为当前声明注册了四个动画描述,用于在不同状态切换时使用不同动画,所以这四个动画都只与注册的 Composable
函数相关。所以:
- 从
A
切换到B
, 对B
应用B
的enter
, 对A
应用A
的exit
- 从
B
返回到A
, 对B
应用B
的popExit
, 对A
应用A
的popEnter
因为动画是提前注册好的,所以会存在一个问题,例如 A
可能跳转 B
, 也可能跳转 C
, 那么跳转时都是应用 A
的 exit
, 那我如果期望一个使用 slide
动画,一个使用 fade
动画该怎么办呢?
仔细观察上面函数的签名,就会发现我们注册时注册的不是动画本身,而是要求传入一个 lambda
函数,其函数的返回值才是动画。所以我们是在不同场景都重新构一个动画,那具体的场景我们该怎么区分呢?
答案就存在这个 lambda
函数是在 AnimatedContentScope<NavBackStackEntry>
域下执行的,这个可以拿到动画 initialState
和 targetState
,具体而言就是新旧界面的 NavBackStackEntry
。 如此就可以根据其做出区分。
其实在原本框架上,NavBackStackEntry
的区分能力还是一般,但是如果使用 scheme
框架的话,那就可以拿到更多的区分信息
// 拿到 scheme fun NavBackStackEntry.readOriginScheme() // 拿到 scheme transition 的声明,具体含义可见下一节 NavBackStackEntry.readTransition() // 拿到 scheme 的 action fun NavBackStackEntry.readAction()
通过这些信息,我们就可以执行丰富的判断。
在了解了这长长的基础后,我们就可以来看看在 scheme
的注解下,该怎么自定义动画。
scheme 转场动画使用
注解 ActivityScheme
和 ComposeScheme
都有一个字段叫 transition
, 其类型是 int
, 指明使用哪一个 SchemeTransitionProvider
,框架提供了几个默认实现:
SchemeTransition.PUSH
: 常规模式,从右边进入, iOS 式命名SchemTransition.PRESENT
: 从底部升起, iOS 式命名SchemTransition.SCALE
: 缩放进入SchemTransition.PUSH_THEN_STILL
: 从右边进入,exit
和popEnter
保持静止,如果从当前界面去往其它界面会有非push
行为,那么就需要使用这个或者完全自定义。
如果你有自定义需求,那么可以往 SchemeTransitionProviders
中注册新的类型与实现
object SchemeTransitionProviders{ // 开发者注册的 type 需要大于 0 fun put(type: Int, provider: SchemeTransitionProvider) fun get(type: Int): SchemeTransitionProvider }
SchemeTransitionProvider
是我们自定义需要实现的接口:
interface SchemeTransitionProvider { // 当以 `activity` 进入时需要提供的资源 fun activityEnterRes(): Int fun activityExitRes(): Int fun enterTransition(): (AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition?)? fun exitTransition(): (AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition?)? fun popEnterTransition(): (AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition?)? fun popExitTransition(): (AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition?)? }
需要说明的是,因为我的 scheme
是支持 Activity
和 Compose
各种搭配乱跳的,所以需要提供 activity
的转场动画,但它是事务型的,是服务于新旧两个界面的。
而其它的几个方法,详细在了解了上一节的基础知识后,也都了解了具体是做什么的了。
那为何说是不那么完美的呢?
其实最好的写法是直接在 ComposeScheme
或 ActivityScheme
中指明 SchemeTransitionProvider
, 例如
@ComposeScheme( action = "action", alternativeHosts = [MainActivity::class], transition = PushSchemeTransitionProvider::class, ) @Composable fun SchemeModelPage(navBackStackEntry: NavBackStackEntry, arg: DataArg){ // 注意顺序不能变更 }
这样就不需要再搞一个 int
,然后去注册了。
那为何没有用这种形式呢? 主要是因为 SchemeTransitionProvider
依赖了 AnimatedContentScope
与 NavBackStackEntry
,而它们又不是纯粹的 java
库,在 ksp
库中无法引入,或者有实现方案,但是我不知道?如果有了解的,欢迎交流。 我也可以用 KClass<*>
,不指明类型,运行时再检查,就像上面 alternativeHosts
做的那样,但是问题就是无法写默认值,每写一个界面就指定一个 transition
, 也有点蛋疼。所以目前我采取的这种注册式的折中方案。
我是古哥E下,前微信读书客户端程序猿 / 自学 5 年中医,维护过上万 Star 开源项目 QMUI Android
,现独立维护好用简洁的 Android
组件库 emo
。
关注我可得:ChatGPT
开发玩法 | 程序员学习经验 | 组件库新变动 | 中医健康调理 。
emo官网:emo.qhplus.cn