Navigation (1)

经过了两个多星期後,我们终於开始进入 presentation layer 的部分。Presentation layer 就是做 UI 相关的东西,例如 ActivityFragmentViewModel 这些 class。而这次要做的部分是要准备基本的 navigation。

我们这个示范 app 会采用 single activity app 的做法,即是整个 app 只会有一个 Activity,所有显示的页面都是用 Fragment 来装住。如果要由一页转去另一页的话原理就是用 FragmentManager 切换显示另一个 Fragment。不过我们不会直接接触 FragmentManager,而是用 AndroidX 的 Navigation component 来帮我们处理。为甚麽原本能用多个 Activity 就做到的东西要转用 single activity app 来做呢?主要原因是处理 deep link 的话 app 只有单一 Activity 是远较多个 Activity 的 app 容易控制,单是 Manifest 入面 <activity>android:launchMode 就搞到头疼,後来更变成 Android 面试的经典题目。如果完全转用 single activity 的做法的话基本上除了要做「一 app 多开」的效果外,基本上都不用特别处理 android:launchMode。「一 app 多开」的正式名称是 task。意思是一个 app 可以在系统的「recent apps」显示好几个视窗,例如以前的 Chrome 开新 tab 都是用这个功能来做到。这个功能在 Word 这类应用非常合适,配合 split screen 来用就可以同时上下显示两个 Word 文件并同时编辑。

说回 navigation 的部分,AndroidX 的 Navigation component 除了处理换页时的 Fragment 切换和 deep link 之外,还有是管理每页传入的参数、换页动画,配搭 Dagger Hilt 的话更可以设定某些 object 的 scope 是跟 navigation graph 共生死。

安装 Navigation component

首先在 project 的 build.gradle 加入 safe args Gradle plugin:

dependencies {
    // ...
    classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigationVersion"
}

然後在 app module 的 build.gradle 加入 Navigation component safe args plugin 和相关的 dependency:

plugins {
    // ...
    id 'androidx.navigation.safeargs.kotlin'
}

dependencies {
    implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion"
    implementation "androidx.navigation:navigation-ui-ktx:$navigationVersion"
}

然後就可以加入我们的 navigation graph,但请同时加入以下的内容,因为本篇会用到:

android {
    // ...
    buildFeatures {
        dataBinding true
        viewBinding true
    }
}

有人说 Navigation component 是 Android 的 Storyboard,确实界面上跟 iOS Storyboard 真的很似。但跟 Storyboard 不同是 Android 的 navigation graph XML 只会储存各页对应的 Fragment class 名、layout XML 名、各页的参数、deep link 和转页的连结等跟 navigation 相关的资讯,各页的界面放了甚麽 View 仍然跟以往一样是放在各个 layout XML 入面。而 iOS Storyboard 档案除了存放 navigation 的资讯外,还包括各页显示的内容,所以页数一多就会变得很慢。这亦都是部分 iOS developer 不用 Storyboard 而用 Interface Builder(XIB 档)或者索性用 code 控制排版的原因。

但继续讨论 Navigation component 之前,我们要先准备 app 那两页的 Fragment class 和 layout XML 档,否则我们开了那个 navigation graph XML 档都不能做任何事情。

StationListFragment

由於我们的 app 有两页,那我们就要准备两个 Fragment class:StationListFragmentEtaFragment,分别对应车站列表和抵站时间页。首先是 StationListFragment

@AndroidEntryPoint
class StationListFragment : Fragment() {
    private var _binding: StationListFragmentBinding? = null
    private val binding: StationListFragmentBinding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = StationListFragmentBinding.inflate(inflater, container, false)
        binding.lifecycleOwner = viewLifecycleOwner
        return binding.root
    }

		override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

首先顶头会有个 @AndroidEntryPoint,这个是 Dagger Hilt 的 annotation。加了它 Dagger Hilt 就会自动替你做 dependency injection(包括 field injection)。现在不明白不要紧,我们会在之後再详细讲解。

我们大部分 layout XML 都会采用 data binding,所以 StationListFragment 会有 StationListFragmentBinding。在 onCreateView 我们会 inflate XML,但因为用了 data binding 所以写法会有小许不同。那句 binding.lifecycleOwner = viewLifecycleOwner 的意思是那个 data binding 用到的 LiveDataStateFlow 会按照 StationListFragment 的 lifecycle 控制何时开始和终结 observation。详细用法要先卖个关子,因为我们这篇的主要目的是弄好两页的壳来准备 navigation graph。

你会看到我们准备了两个 StationListFragmentBinding 的 property,一个是 nullable 一个就不是 nullable。而在 onDestroyView 我们把 _binding 设回 null 是为了防止 memory leak 和 view 已经从 Activity 中移除,不应再 reference 住它。但因为把 view binding 或 data binding 的 property 弄成 nullable 在使用上不够方便,所以就出现了另一个不是 nullable 的 property 来把 _binding 强行 access。

而 layout XML 我们叫它做 station_list_fragment.xml,这是 StationListFragmentBinding 命称的来源(它会自动把命称转为 camel case 并加上後缀 Binding)。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

    </data>

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <com.google.android.material.appbar.AppBarLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <com.google.android.material.appbar.MaterialToolbar
                android:id="@+id/topAppBar"
                style="@style/Widget.MaterialComponents.Toolbar.Primary"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:title="@string/app_name" />
        </com.google.android.material.appbar.AppBarLayout>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_behavior="@string/appbar_scrolling_view_behavior" />
    </androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

EtaFragment

另一页要准备的是 EtaFragment,内容都是跟之前差不多,只是换了个名字。

@AndroidEntryPoint
class EtaFragment : Fragment() {
    private var _binding: EtaFragmentBinding? = null
    private val binding: EtaFragmentBinding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = EtaFragmentBinding.inflate(inflater, container, false)
        binding.lifecycleOwner = viewLifecycleOwner
        return binding.root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

对应的 layout XML 是 eta_fragment

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

    </data>

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <com.google.android.material.appbar.AppBarLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <com.google.android.material.appbar.MaterialToolbar
                android:id="@+id/topAppBar"
                style="@style/Widget.MaterialComponents.Toolbar.Primary"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:title="@string/app_name" />
        </com.google.android.material.appbar.AppBarLayout>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_behavior="@string/appbar_scrolling_view_behavior" />
    </androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

改 theme

从那两个 layout XML 看到我们每页都会有自己的 top bar (AppBarLayout),所以 theme 就不应该用带有 top bar(以前叫 action bar)的 theme。

Project 本身是用 Android Studio 的 template 建立,所以会有 themes.xml

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.ETADemo" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
        <!-- 略 -->
    </style>
</resources>

我们要把 Theme.MaterialComponents.DayNight.DarkActionBar 换成无 action bar 的 theme:

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.ETADemo" parent="Theme.MaterialComponents.DayNight.NoActionBar">
        <!-- 略 -->
    </style>
</resources>

这篇的 code 有点长,我们下一篇会写 navigation graph 的部分和把 MainActivity 改成显示那个 navigation graph 的页面。

参考


<<:  [Day17] Cloud Run

>>:  Re-architect - Domain Layer (二)

Day9. functional programming in Ruby - Block Part2

初来乍到Ruby世界的读者们,绝对想不到原来Ruby 也有 curry, bind 等用法。这些语法...

Day 21:在 Hexo 增加作者版权声明(使用 Next 布景)

内容发布到网路上,由於都是开放的,不管是你写的文章、拍摄的相片或是影片,有一定的机率会被转贴。有些人...

[Day 04] 部署模型的挑战 — 资料也懂超级变变变!?

部署模型有两个主要的挑战,事实上这两个挑战隐含了机器学习产品生命周期里的 "部署 (Dep...

认识与建立CSS样式表(DAY7)

在第一篇介绍时提到Html就像礼物的实体,而我们现在要认识的CSS样式表就像是礼物的外包装,要如何包...

那些被忽略但很好用的 Web API / ScrollIntoView

将元素玩弄与指尖,说来就来,呼风唤雨 既然有 IntersectionObserver 能够侦测元...