推销 Compose 跨平台 Navigation:PreCompose

3,587 阅读6分钟

先上 Github 链接:github.com/Tlaster/Pre…

前言

希望大家都写过 Jetpack Compose 了,没写过的抓紧时间,该卷起来了。

Android 上现在大家都在用 Jetpack 全家桶,什么 ViewModel、Navigation、Lifecycle,用起来就一个字:爽。然后再按照官方推荐的应用架构组织一下应用,整个应用写起来那是一个行云流水得心应手,整个人都变成了 Jetpack 的形状。

我们都知道的著名的 Jetbrains(喷气大脑们)在去年发布了 Compose for Desktop,很新鲜,大家都想来玩玩,但是一个大问题摆在大家面前:JVM 上可没有 Jetpack 这么多组件。
在官方 Github Repo 里面讨论最多的 issue 就是:Compose Navigation 啥时候有?

有一个解决方案就是使用 Decompose,这个库的核心思想就是:UI 和业务逻辑和导航逻辑三者严格分离,所以这个库能做到在适配 Compose 的同时还可以适配 SwiftUI,理论上说,如果你只是需要业务逻辑跨平台共享,UI 各个平台实现的话,这个库也就足够了。但是 Decompose 在写法上与我们熟悉的 Jetpack ViewModel 和 Navigation 有所不同,需要一定的时间适应。

那么有没有那么一个库,他不仅业务逻辑可以帮你跨平台共享,UI 也可以使用 Compose 跨平台共享,还能让你像使用 Jetpack Navigation 和 ViewModel 那样写代码呢?

推销 PreCompose

PreCompose 是一个 Kotlin Multiplatform 库,目的是为了让 Kotlin 开发者能够像 Flutter 那样只需要写一套业务逻辑代码和一套 Compose UI 代码就可以在各个平台上运行,同时还能用上大家熟悉的 ViewModel 和 Navigation,目前支持 Android/iOS/Dekstop。

ViewModel

之前使用 Jetpack ViewModel 的时候怎么写,在 PreCompose 里面就怎么写,比如
定义一个 ViewModel

class HomeViewModel : ViewModel() { }

还可以使用 viewModelScope 来运行 suspend 函数。

在 Compose 里面可以这样使用 ViewModel

@Composable
fun HomeScene() {
    val viewModel = viewModel {
        HomeViewModel()
    }
    //...
}

在 Jetpack 里面我们都知道,如果你的 ViewModel 带参数,那还挺麻烦的,在 PreCompose 里面你只需要这样

@Composable
fun SomeScene(someParameter: String) {
    val viewModel = viewModel(keys: listOf(someParameter)) {
        SomeViewModel(someParameter)
    }
    //...
}

如果 someParameter 发生变化,那么 ViewModel 就会被销毁重建,除此之外你每次拿到的都是同一个 ViewModel 的实例。

Navigation

同样,之前使用 Jetpack Navigation 怎么写,在 PreCompose 里面就怎么写,不过略微有点 API 上的不同。 先来一个简单的例子吧。

@Composable
fun Router() {
    // 定义一个 Navigator,和 Jetpack Navigation 里面的 NavController 功能一样
    val navigator = rememberNavigator()
    NavHost(
        // 将 Navigator 给到 NavHost
        navigator = navigator,
        // 定义初始导航路径
        initialRoute = "/home",
        // 自定义页面导航动画,这个是个可选项
        navTransition = NavTransition(),
    ) {
        // 在 navigation graph 中定义一个页面
        scene(
            // 这个页面的路由路径
            route = "/home",
            // 这个页面单独定义的页面导航动画,这个是个可选项
            navTransition = NavTransition(),
        ) {
            Text(text = "Hello!")
        }
    }
}

Navigator

Navigator 和 Jetpack Navigation 里面的 NavController 功能一样,有这些方法:

  • Navigator.navigate(route: String, options: NavOptions? = null)
  • Navigator.goBack()
  • Navigator.canGoBack: Boolean

嗯,还挺简洁的。

NavOption

和 Jetpack Navigation 里面的 NavOption 几乎一模一样。你可以使用launchSingleTop,或者PopUpTo,比如

navigator.navigate(
    "/home",
    NavOptions(
        // 指定目标路由启动方式为 single top
        launchSingleTop = true,
    ),
)
navigator.navigate(
    "/detail",
    NavOptions(
        popUpTo = PopUpTo(
            // 要 popupto 的目标路由
            route = "/home",
            // 上面这个目标路由是否也要从导航栈中删除。
            inclusive = true,
        )
    ),
)

路由定义

静态路由

scene(route = "/home") {

}

带参数

scene(route = "/detail/{id}") { backStackEntry ->
    val id: Int? = backStackEntry.path<Int>("id")
}

这些都是挺常见的用法了,但是还支持更多

可选路径参数

scene(route = "/detail/{id}?") { backStackEntry ->
    val id: Int? = backStackEntry.path<Int>("id")
}

你看这个{id}?后面带个问号,这代表这个路由会匹配一下几个:

  • /detail
  • /detail/123
  • /detail/asd

路径参数正则匹配

scene(route = "/detail/{id:[0-9]+}") { backStackEntry ->
    val id: Int? = backStackEntry.path<Int>("id")
}

QueryString

有人可能已经注意到了,怎么在定义里面没有 QueryString?Jetpack Navigation 是需要在路由定义里面加上 QueryString 的,而 PreCompose 则不需要。
举个例子,比如说你要路由到Navigator.navigate("/detail/123?my=query"),你可以这样获取 QueryString

scene(route = "/detail/{id}") { backStackEntry ->
    val my: String? = backStackEntry.query<String>("my")
}

跳转动画

PreCompose 还支持页面间跳转动画,你可以在NavHost上定义动画,也可以在scene上定义动画,优先级是scene > NavHost。而且借助于 Compose Animation API,用起来也是非常的简单的。

NavTransition(
    /**
     * 当页面第一次出现,进入导航栈时的动画,类似于 Activity#onCreate 的时候
     */
    createTransition = fadeIn() + scaleIn(initialScale = 0.9f),
    /**
     * 当页面完全消失,从导航栈中删除时候的动画,类似于 Activity#onDestroy 的时候
     */
    destroyTransition = fadeOut() + scaleOut(targetScale = 0.9f),
    /**
     * 当有新页面要进入导航栈时当前页面的动画,类似于 Activity#onPause 的时候
     */
    pauseTransition = fadeOut() + scaleOut(targetScale = 1.1f),
    /**
     * 当新页面退出导航栈时上一个页面的动画,类似于 Activity#onResume 的时候
     */
    resumeTransition = fadeIn() + scaleIn(initialScale = 1.1f),
)

内部实现

其实就是完全复刻了一遍 Jetpack Navigation、Lifecycle 和 ViewModel,其实也不复杂,网上这么多深度解析 Navigation、Lifecycle 和 ViewModel 的文章,我想大家都已经看过了无数次,烂熟于心了吧。

有时间再完整写一下一些细节实现吧。

FAQ

  • Q: 这个 PreCompose 和官方 Jetpack 这么像,为什么要用 PreCompose 呢?
  • A: 因为这个是跨平台,不仅仅支持 Android,还支持 iOS 和 Desktop,对的你没看错,支持 iOS。
  • Q: iOS 支持?Compose 现在支持 iOS 了?
  • A: 是的,compose-jb 1.2.0 之后就通过 Kotlin Native 支持了 iOS,同时我们组内也有项目在完全去除掉 JVM 依赖之后成功的将 Compose 应用移植到了 iOS 上,这件事值得另一篇文章。
  • Q: 这个 PreCompose 的 Navigation 支持 Parcelable/Serializable 吗?
  • A: 不支持,可以看看官方推荐的应用架构,在这个架构下我不觉得需要有这样的支持。
  • Q: 有什么在使用这个框架的应用吗?
  • A: 有的,比如 Twidere X,这是一个第三方 Twitter/Mastodon 客户端,支持 Android 和 Desktop 平台,同时 Twidere X 也是从 Compose 还在 alpha 阶段就完全接入 Compose 的应用。

后话

其实 PreCompose 诞生之初只是为了解决 Compose 没有 Navigation 的问题,那时候 Jetpack Navigation 都还没有 Compose 支持,然而等了很久在官方还没有动静的情况下就搞了个 PreCompose,然后成功搭上了 Jetbrains Compose 的车,实现了跨平台到 Desktop 和 iOS,还是挺不错的。

不过这并不是我们组内现在正在使用的写法,在 Twidere X 实践了 PreCompose 之后,我们在此基础上设计出了一套全新的不依赖 ViewModel 甚至不依赖于 Class 的业务逻辑代码组织方式。我们都知道 Composable 函数可以让你的 UI 很方便的组合起来,那么有没有一种可能:业务逻辑也是可组合的。
这个就留给下一篇文章吧,我是说如果我有时间写下一篇的话。