Jetpack Compose navigation + Koin

现在我们有了编辑便利贴页面还有编辑文字页面,该是时候好好的来思考要怎麽切换页面了!流程如下:使用者选择了某一个便利贴→看到选单出现→点击编辑文字→跳转到编辑文字页面→编辑完文字後点击确认→回到便利贴页面并且看到更新。其中我们有几个问题可以来好好思考:

  1. Composable function 之间是如何转换页面的?
  2. 转换页面时要传送什麽资料给编辑文字页面?编辑的文字内容?还是便利贴 ID?
  3. 要怎麽更新资料到 Firebase?

所以我们有以下两个选择:第一个作法如下图左所示,编辑文字页面只负责更改文字,然後将更改的结果传回去,最後再交给便利贴页面来去做更新。第二个作法如下图右所示,编辑文字页面有编辑便利贴文字的权限,一开始接收到便利贴的 id ,之後用这 id 去查询到相对应的文字,最後在使用者确定要进行更新时,直接去更改 Firestore 上面的资料,当回到便利贴页面时,因为资料绑定,所以马上就能看到刚刚已经更新的资料。

Screen Shot 2021-09-17 at 8.48.05 PM.png

如果是第一个做法的话,编辑文字页面就会非常简单,职责非常少,但是便利贴页面就会相对的负担比较多的责任,在接收到上个页面回传的结果时,因为 Single source of truth ,还不能直接在 View 层更新资料,必须要再经过 ViewModel 、Repository 这两个元件来传递最新的资料,最後才能看到 Firebase 来的更新。

至於第二个做法的话,就是将便利贴这个 Domain 的知识“染指”了编辑文字页面,因此编辑文字页面未来的可重用性几乎降为 0 ,这是个只为了编辑“便利贴”的文字而生的页面,但是另一方面来说,由於他们共享了“便利贴”这个 Domain ,所以更新文字的任务可以交给这个页面。

那这两个做法哪个比较好呢?很可惜的这没有正确的答案,如果编辑文字页面是一个功能非常丰富的文字编辑器,想要在其他 App 或是不同的应用场景中使用的话,那就会是第一个做法会比较好。反之,如果这个编辑文字页面,跟便利贴 Domain 息息相关,甚至还需要获取或更改更多便利贴的资料时,那就会是第二个做法比较好,因此技术解决方案是与需求有着很高的连结关系的。但是以目前来说,采用第二个做法的技术挑战会少一点,所以这边我选择第二个做法,但是同时开放第一个做法的选项,随时意识到有这种选项的存在。

好了,思考完各种可能性後,现在再回过头来回答第一个问题,Jackpack Compose 的页面转换要怎麽做呢?

Navigation Compose

Google 有为了 Jetpack Compose 做了一个 Navigation library ,其中的概念与 Navigation Component 是差不多的,先来看看基本的用法吧!

build.gradle

dependencies {
    implementation("androidx.navigation:navigation-compose:2.4.0-alpha09")
}

目前是 alpha 版本,未来 API 很有可能会改动喔!

NavController

navController 可以用来追踪所有 Compose 页面的状态,包含页面的堆叠、返回以及传递资料。

val navController = rememberNavController()

NavHost

NavHost 帮我们管理了所有的 Navigation graph ,与 navController 是一对一的对应关系,在 NavHost 底下,可以透过 navigation DSL,让我们得以轻松的描述出页面与页面之间的交互关系,其中每一个页面的路径都是以 String 来表示,这种定义方式也让我们可以用比较自由的方式来描述页面与页之间的关系(举例来说,可以用 Url 来当作页面的路径)。最後,在 NavHost 中,如果要定义一个独立的页面,就要使用 composable 这个关键字:

NavHost(navController = navController, startDestination = "profile") {
    composable("profile") { Profile(/*...*/) }
    composable("friendslist") { FriendsList(/*...*/) }
    /*...*/
}

在上面的范例中,设定了 startDestination ,也就是第一个页面为 "profile",因此在 NavHost 被执行的时候,会先去执行 Profile() 这边的 composable function 。

转换页面以及传递资料

Navigation library 中有很多种不同的传递资料方式,这边只介绍最简单的一种(也因为我只需要用到这样就好):

NavHost(startDestination = "profile/{userId}") {
    ...
    composable("profile/{userId}") { backStackEntry ->
        Profile(navController, backStackEntry.arguments?.getString("userId"))
    }
}

在路径中可以加入 /{xx} 来当作这个页面的参数,而获取这个参数的方式是利用 composable 这个函示中的 content 获得。这个 content 其实就跟其他的 Composable function 是一样的用途,利用了 Kotlin 的特性,最後一个参数是函式时,就可以像 Row 一样是用来包装其他的 Composable function 在里面的,只不过这个 content 预设带了一个 NavBackStackEntry 来让里面的程序可以拿到更多关於 navigation 的资讯,下面是 composable 的原始码:

Screen Shot 2021-09-17 at 10.22.22 PM.png

最後则是在 NavHost 使用 navController 的方式:

navController.navigate("profile/user1234") // 导到 profile 页面并带有 user1234 这个参数
navController.popBackStack() // 跳回上一个页面

实作

在这个 App 中我只有两个页面,如下面定义:

sealed class Screen(val route: String) {
    object Board : Screen("board")
    object EditText : Screen("editText") {
        const val KEY_NOTE_ID = "noteId"
    }
}

我们应该要尽量避免使用字串,透过定义好的类别来切换页面可以避免不小心的打字错误,然後下面则是这专案的 Navigation graph 设定:

// 第一次出现的 MainActivity XD
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val navController = rememberNavController() // [0]

            ReactiveStickyNoteTheme {
                NavHost(navController, startDestination = Screen.Board.route) { // [1]
                    composable(Screen.Board.route) { // [2]
                        val viewModel by viewModel<EditorViewModel>() // [3]
                        EditorScreen(
                            viewModel = viewModel,
                            openEditTextScreen = { id ->
                                navController.navigate(Screen.EditText.route + "/" + id) // [4]
                            }
                        )
                    }

                    composable(
                        Screen.EditText.route + "/" + "{${Screen.EditText.KEY_NOTE_ID}}" // [5]
                    ) { backStackEntry ->
                        val viewModel by backStackEntry.viewModel<EditTextViewModel> { // [6]
                            parametersOf(backStackEntry.arguments?.getString(Screen.EditText.KEY_NOTE_ID)) // [7]
                        }
                        EditTextScreen(viewModel, onLeaveScreen = { navController.popBackStack() }) // [8]
                    }
                }

            }
        }
    }
}
  • [0]:在最上层获取 navController ,用来控制页面之间的切换
  • [1]:指定 startDestination 为 Screen.Board.route ,所以第一个开启的页面将会是 Screen.Board
  • [2]:这一整个 composable 的区块都会是 Screen.Board 的范围,基本上就是放这页面要用来显示的 Composable function 。
  • [3]:Koin 的 delegate 语法,有用过 Koin 的读者应该会很熟悉这个语法。
  • [4]:将这个 openEditTextScreen lambda 带进去 EditorScreen 当作第二个参数,当 openEditTextScreen 被呼叫的时候就可以触发 navController.navigate 因而开启下一个页面。
  • [5]:上面介绍过,如果要传递参数的话可以使用这个方式,将这段程序码转成字串的话将会是 editText/{noteId}
  • [6]:backStackEntry.viewModel 是这段程序码中最关键的部分,稍後会再详细解释。
  • [7]:藉由 backStackEntry 获取传递进来的参数
  • [8]:与 [4] 一样,在 lambda 中透过 navController.popBackStack 回到上一个页面

注:这边使用 Koin 的版本是 “io.insert-koin:koin-android:3.0.2”,跟 2.0 的用法是有稍微的不同的喔。

ViewModel 的生命周期

以往大家所认识的 ViewModel 都是跟 Activity 或是 Fragment 绑在一起的,如果 ViewModel 是在 Fragment 中宣告,当 Fragment 被回收时,该 ViewModel 也会一起被回收,Activity 也是同理。那如果有一个 ViewModel 不想要跟 Fragment 的生命周期绑在一起而是要改成 Activity 的时候怎麽办呢?很幸运的 Koin 也有提供 sharedViewModel() 这个函式来帮我们轻松做到这件事。

但是实际上 ViewModel 生命周期的运作机制是怎麽运行的呢?谁会有办法去建立实例又在适合的时候进行回收呢?请看以下类别图:

ViewModel.png

Activity 以及 Fragment 都实作了 ViewModelStoreOwner ,这是一个很简单的介面,只有一个 getViewModelStore() 的函式,顺带一提,这样子的介面就是是工厂模式(Factory pattern),也就是继承这介面的实作负责生成 ViewModelStore 的实体。

接下来看看 ViewModelStore 这个类别,这个类别是一个储存 ViewModel 的容器,put() 可以新增一个 ViewModel ,get() 则是从这个容器中拿出相对应的 ViewModel,clear() 用来结束这容器内所有 ViewModel 的生命周期。因此,ViewModelStoreOwner 拥有着所有 ViewModel 生命周期的控制权,如果是 Activity ,就会在 onDestroy 的时候呼叫 clear() , Fragment 则是比较复杂一点就没有深入往下追了,以下是 ViewModelStore 的实作:

public class ViewModelStore {

    private final HashMap<String, ViewModel> mMap = new HashMap<>();

    final void put(String key, ViewModel viewModel) {
        ViewModel oldViewModel = mMap.put(key, viewModel);
        if (oldViewModel != null) {
            oldViewModel.onCleared();
        }
    }

    final ViewModel get(String key) {
        return mMap.get(key);
    }

    Set<String> keys() {
        return new HashSet<>(mMap.keySet());
    }

    /**
     *  Clears internal storage and notifies ViewModels that they are no longer used.
     */
    public final void clear() {
        for (ViewModel vm : mMap.values()) {
            vm.clear();
        }
        mMap.clear();
    }
}

ViewModelStoreOwner, ViewModelStore, ViewModel 这三个类别之间的关系非常紧密,其中最让人佩服的是这设计极其简单,让人马上看懂,同时又可以兼顾很多不同的使用情境,简直就是物件导向设计的极佳范例,不只符合了SOLID principle 中的 Single Responsibility principle, Dependency inversion principle ,充分结合了工厂模式。也没有冗余的类别相依,不用依靠 android.util 或是 android.graphics 就可以单独存在,在写单元测试的时候没有额外的负担。

好了,吹捧完了之後还是得来归纳一些重点:

  1. ViewModel 的生命周期是由 ViewModelStoreOwner 控制的
  2. 要在建立 ViewModel 的时候为他选择适当的 ViewModelStoreOwner,该使用 Fragment 的时候就不要选择 Activity ,不然会拿到重复的 ViewModel 而造成不可预期的 bug。

爲 EditTextViewModel 挑选最适当的 ViewModelStoreOwner 吧!

为了怕大家忘记,我们再看一次页面跳转的流程吧!

Screen Shot 2021-09-18 at 10.25.22 AM.png

EditorTextScreen 在打开的时候,就会去依据 noteId 去建立一个新的 EditTextViewModel ,然後在页面关闭的时候,就必须要回收这个实例,不然在下次要编辑另一个便利贴的文字的时候,就会因为没有回收实例而重用同一个 EditTextViewModel ,这时候编辑的内容就会是错误的,可想而知,如果我们使用的一直都是 Activity 这个 ViewModelStoreOwner 的话,就会造成重用实例的这个问题。

然而如果使用 Koin 预设所提供的 delegate 语法来建立或获取 ViewModel 实例时,其中的 ViewModelStoreOwner 就会是 Activity ,所以我们不能无脑的直接使用 Koin 提供的 viewModel() 。 那我们可以怎麽做呢?还记得上面的程序码吗?我在这边使用了 backStackEntry 来建立或获得 ViewModel ,那这又是怎麽运作的呢?让我们来看看 NavBackStackEntry 的实作:

public class NavBackStackEntry private constructor(
  ... // 这边可以略过
): LifecycleOwner,
    ViewModelStoreOwner,
    HasDefaultViewModelProviderFactory,
    SavedStateRegistryOwner {

这边我们发现了 NavBackStackEntry 其实实作了 ViewModelStoreOwner ,那我们再来看看 getViewModelStore() 这个函示中做了什麽事:

public override fun getViewModelStore(): ViewModelStore {
    check(lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
        "You cannot access the NavBackStackEntry's ViewModels until it is added to " +
            "the NavController's back stack (i.e., the Lifecycle of the NavBackStackEntry " +
            "reaches the CREATED state)."
    }
    checkNotNull(viewModelStoreProvider) {
        "You must call setViewModelStore() on your NavHostController before accessing the " +
            "ViewModelStore of a navigation graph."
    }
    return viewModelStoreProvider.getViewModelStore(id)
}

NavBackStackEntry 在这边又委托了 viewModelStoreProvider 来获取 ViewModelStore,这样的方式代表了 ViewModelStore 的数量不是只有一个,而是多个。而且该 ViewModelStore 还绑着一个相对应的 id ,因此我们可以判断这些 ViewModelStore 也有着不同的生命周期,每个不同ViewModelStore 会有相对应的画面,随之而生,也随之消灭。下图示意了 ViewModelStore 在 NavBackStackEntry 中的状态:

Screen Shot 2021-09-18 at 10.54.02 AM.png

在时间轴的最左边,打开了 ScreenA ,到了最後也没结束它,所以 ViewModelStoreA 一直都存在着。 ScreenB 也是同理,但是 ViewModelStoreB 是在打开 ScreenB 的时候才会去建立的,在这个时候 ViewModelStoreA 跟 ViewModelStoreB 是同时存在的,因此我们可以在 ScreenB 拿到在 ScreenA 中建立的 ViewModel ,他们就跟 Fragment 与 Activity 的关系一样。接下来看到 ScreenC : 在这段时间轴中,ScreenC 被建立了两次,因此相对应的 ViewModelStoreC 也会不同,所以这两次开启 ScreenC 中的 ViewModel 也会是不同的实体。

最後再来看看之前写的程序,是不是一切都通了呢?

composable(
    Screen.EditText.route + "/" + "{${Screen.EditText.KEY_NOTE_ID}}"
) { backStackEntry ->
    val viewModel by backStackEntry.viewModel<EditTextViewModel> { 
        parametersOf(backStackEntry.arguments?.getString(Screen.EditText.KEY_NOTE_ID))
    }
    EditTextScreen(viewModel, onLeaveScreen = { navController.popBackStack() }) 
}

使用 backStackEntry.viewModel 就可以拿到最符合该“Scope”所需要的 ViewModel,这样就行了!甚至用不到 Koin 所设计的 Scope API 。

Scope 是什麽?在使用 Dependency Injection 时,Scope 也是一个需要被考虑的点,他能够控制指定物件的存活时间,其中最简单的例子就是 Activity Scope,Activity Scope 中的所有物件会随着 Activity 的回收而一起被销毁,如果没有好好的运用这机制的话,整个 App 将会充满了“有状态”的 Singleton ,非常不好管理。

EditTextViewModel 的实作

class EditTextViewModel(
    private val noteRepository: NoteRepository,
    noteId: String
) : ViewModel() {

    private val disposableBag = CompositeDisposable()

    private val noteSubject = BehaviorSubject.create<Note>()
    private val textSubject = BehaviorSubject.createDefault("")
    private val leavePageSubject = PublishSubject.create<Unit>()

    val text: Observable<String> = textSubject.hide()
    val leavePage: Observable<Unit> = leavePageSubject.hide()

    init {
        noteRepository.getNoteById(noteId)
            .firstElement()
            .fromIO()
            .subscribe { note ->
                noteSubject.onNext(note)
                textSubject.onNext(note.text)
            }
            .addTo(disposableBag)
    }

    fun onTextChanged(newText: String) {
        textSubject.onNext(newText)
    }

    fun onConfirmClicked() {
        noteSubject.withLatestFrom(textSubject) { note, text ->
            note.copy(text = text)
        }
            .subscribe { newNote ->
                noteRepository.putNote(note = newNote)
                leavePageSubject.onNext(Unit)
            }
            .addTo(disposableBag)
    }

    fun onCancelClicked() {
        leavePageSubject.onNext(Unit)
    }
}

这边没有新概念,所以...看看应该就知道这是怎麽运做的了(其实是懒得解释XD)。

小结

今天分析了两种不同的页面转换方式,其实 Android 官方是建议第二种做法的,藉由 ViewModel 来更新资料,而不是在关闭页面之後回传资料,但是我认为关闭页面回传资料还是有存在的价值的,一切端看使用当下的上下文而定(也就是大家很常说的 Context)。很遗憾的是第一种做法在 Jetpack Compose 中我看到的做法都有点麻烦,希望之後会有好用一点的 API 。

另外,在本文的後面还介绍了 "Scope" 的这个概念,在单一 Activity 的架构下这个概念非常重要,没有好好使用的话,就会开始想各种 workaround 来把之前的状态清掉(因为实际上使用同一个 ViewModel),对长期维护来说会是一个很大的负担。


<<:  Day10 Pandas模组二

>>:  Day24 ( 游戏设计 ) 记忆大考验

21 "准备完成" 用 PubSub 同步更新网页

拉出 component Component 除了在同一个 module 用之外也能拉出来放 我们来...

卡夫卡的藏书阁【Book30】- Kafka - Sum up

“One of the first signs of the beginning of under...

Day04:团队的组成

当要开发一个大型专案的时候,往往会落入一个错误的认知,就是认为投入的人数越多,开发越快。 软件开发需...

事件检视器的使用介绍(二)--事件分类与筛选

今天要来分析各日志档的内容跟类型的判别,也来看一点Event ID(事件识别码)查一点小事件,Win...

[Day5] 学 Bootstrap 是为了走更长远的路 ~ Flex 篇

前言 这几天写下来, 真的深深感受到我参加的是「自我挑战组」, 真的每天都在 自我挑战 跟天窗奋斗o...