Navigation (2)

在 Android,navigation graph 是 resource 的一种,我们先建立 eta.xml

https://ithelp.ithome.com.tw/upload/images/20211003/20139666dM0xK0mbeo.png

先附上完整的内容,然後再慢慢讲解入面的意思。

<?xml version="1.0" encoding="utf-8"?>
<navigation 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"
    android:id="@+id/eta"
    app:startDestination="@id/stationListFragment">

    <fragment
        android:id="@+id/stationListFragment"
        android:name="net.swiftzer.etademo.presentation.stationlist.StationListFragment"
        android:label="StationListFragment"
        tools:layout="@layout/station_list_fragment">
        <action
            android:id="@+id/action_stationListFragment_to_etaFragment"
            app:destination="@id/etaFragment" />
    </fragment>
    <fragment
        android:id="@+id/etaFragment"
        android:name="net.swiftzer.etademo.presentation.eta.EtaFragment"
        android:label="EtaFragment"
        tools:layout="@layout/eta_fragment">
        <argument
            android:name="line"
            app:argType="net.swiftzer.etademo.common.Line" />
        <argument
            android:name="station"
            app:argType="net.swiftzer.etademo.common.Station" />
    </fragment>
</navigation>

切换到 Design 後就能看到两页的 layout XML 预览画面和各页之间的导航方向(就是两页之间的箭头)。由於下图是我在完成 EtaFragment 基本功能後才撷取所以内容比较丰富,如果按照上一篇来做的话应该只会看到两页只得 top bar 和下面一大片空白。

https://ithelp.ithome.com.tw/upload/images/20211003/20139666lX3NVriQyJ.png

stationListFragment 左上角有个小屋 icon,意思是那一个 navigation graph 的首页。首页的意思是一进入那个 navigation graph 会看到那页,每个 navigation graph 都要指明一个 Fragment 做首页。在 XML 的写法是在 <navigation> tag 的 app:startDestination 指明那页的 ID (@id/stationListFragment)。

而每页的定义就是用 <fragment> tag 定义,以 StationListFragment 为例:

<fragment
    android:id="@+id/stationListFragment"
    android:name="net.swiftzer.etademo.presentation.stationlist.StationListFragment"
    android:label="StationListFragment"
    tools:layout="@layout/station_list_fragment">
    <action
        android:id="@+id/action_stationListFragment_to_etaFragment"
        app:destination="@id/etaFragment" />
</fragment>
  • android:id 就是用来给那个 navigation 项目定义一个 ID,方便我们引用到(那个 app:startDestination 就是例子)
  • android:nameFragment 的全名
  • android:label 跟 manifest 那个 <activity> 入面的 android:label 作用差不多,就是给那页一个让人看的名称
  • tools:layout 就是为了在 IDE 预览时能看到 layout XML 而设,看到 namesapce 是 tools 就知道了,不写那个都不会影响运行效果

至於入面的 <action> 就是设定这页可以跳到另一页,有点像 iOS Storyboard 的 segue。在这个例子我们设定它能跳到 EtaFragment。其实那个 <action>android:id="@+id/action_stationListFragment_to_etaFragment" 是由 Design 介面拖曳 StationListFragment 右边的圆形再在 EtaFragment 放手自动生成出来的,不用担心要自己写这麽长的名字。

我们转去看另一个 <fragment>,它有另一款 tag 叫 <argument>。这个 tag 是定义开启 Fragment 的 argument。传统启动 ActivityFragment 如果要附带参数的话都会用到 intent extras 或 arguments 传递 Bundle。即使用了 Navigation component 这亦不变,变的地方是把参数定义在 XML 内。配合 safe args Gradle plugin 就可以令这个过程更 type safe。以往如果要做到 type safe 效果会写一个 static 的 newInstance method 把这些建立 Bundle 的 code 塞进去,其他地方就不用知道那个 key 是甚麽,而且不会用错 data type,但要每个 ActivityFragment 都要做一次这个 static method。我们会在之後实际写 StationListFragmentEtaFragment 时再仔细介绍。

 <fragment
    android:id="@+id/etaFragment"
    android:name="net.swiftzer.etademo.presentation.eta.EtaFragment"
    android:label="EtaFragment"
    tools:layout="@layout/eta_fragment">
    <argument
        android:name="line"
        app:argType="net.swiftzer.etademo.common.Line" />
    <argument
        android:name="station"
        app:argType="net.swiftzer.etademo.common.Station" />
</fragment>

MainActivity

定义好 navigation graph 之後,我们要改改原本在 Android Studio 范本的 MainActivity

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private lateinit var binding: MainActivityBinding
    private lateinit var navController: NavController

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = MainActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)
        val navHostFragment =
            supportFragmentManager.findFragmentById(binding.navHostFragment.id) as NavHostFragment
        navController = navHostFragment.navController
    }

    override fun onNewIntent(intent: Intent?) {
        super.onNewIntent(intent)
        navController.handleDeepLink(intent)
    }

    override fun onSupportNavigateUp(): Boolean = navController.navigateUp()
}

同样地,我们会加上 @AndroidEntryPoint。这是因为这个 Activity 将会加载带有 @AndroidEntryPointFragment,所以即使这个 Activity 看起来没有用到 Dagger Hilt 我们还是需要加 @AndroidEntryPoint。如果不加的话之後遇到带有 @AndroidEntryPointFragment app 就会 crash。虽然 Dagger 标榜是透过 compile 时检查 DI 的设定,但并不包括这部分。

这次我们会用上 view binding,其实跟 data binding 有几分相似,但不能 pass variable 去 layout XML 用,亦不能在 layout XML 加入 Java code。它的作用就是让你不用再写 findViewById 取得 layout XML 的 view。它比以前的 Kotlin syntheticsButterknife 好在它能分析到那些 view ID 是不是只在个别 configuration 才会出现,如果是的话 binding 就会外露 nullable 的 view 让你引用。

如果在 Fragment 用 view binding 的话同样都需要在 onDestroyView 终止所有 binding 引用,好让那些 view 能被 garbage collection。而 Activity 因为它的 lifecycle 跟 view 一样,所以不用像 Fragment 用 view/data binding 般要在 onDestroyView 清除引用。(因为 Fragment 比那些 view 长命)

整个 MainActivity 只是需要做三件事:

  1. inflate layout XML,入面会有一个 FragmentContainerView 用来显示 navigation graph 的 Fragment(就是显示每页内容的位置)
  2. onNewIntent 时将 Intent 交予 NavController 处理转页(就是 app 开了後再启动 deep link 时通知 Navigation component 转去对应页面,虽然这个示范 app 不会有 deep link 但还是示范给你们看)
  3. onSupportNavigateUp 时通知 NavController 跳到 navigation graph 的上一页或者上一层 graph(这个是按下 action bar 的 back 按钮时会触发的 callback,虽然我们不是用系统的 action bar 而是用自行加入的 AppBarLayout 但为防日後不经意地用了原装 action bar 所以还是加了)

下面就是 MainActivity 的 layout xml (main_activity.xml)。由於我们的 app 的 toolbar 都是由各自 Fragment 自行处理,所以全个 MainActivity 只需要放一个 FragmentContainerView 就可以了。留意 android:nameapp:defaultNavHostapp:navGraph 这三个 attribute。

<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView 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"
    android:id="@+id/navHostFragment"
    android:name="androidx.navigation.fragment.NavHostFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:defaultNavHost="true"
    app:navGraph="@navigation/eta"
    tools:context=".MainActivity" />

如果打算加 deep link 的话可以在 manifest 对应 MainActivity<activity> 加入 <nav-graph>,这样你就不用针对每个 deep link 加上 <intent-filter>

<activity
    android:name=".MainActivity"
    android:exported="true">
    <nav-graph android:value="@navigation/eta" />
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

完成後执行 app 应该会看到一个只有 toolbar 和空白内容的页面。这个将会是下一篇会做的部分:车站列表。

R8 注意事项

如果现在执行有 R8 处理过的 app 的话,一打开 app 就会 crash:

2021-09-23 22:28:06.228 10900-10900/? E/AndroidRuntime: FATAL EXCEPTION: main
    Process: net.swiftzer.etademo, PID: 10900
    java.lang.RuntimeException: Unable to start activity ComponentInfo{net.swiftzer.etademo/net.swiftzer.etademo.MainActivity}: android.view.InflateException: Binary XML file line #11 in net.swiftzer.etademo:layout/main_activity: Binary XML file line #11 in net.swiftzer.etademo:layout/main_activity: Error inflating class androidx.fragment.app.FragmentContainerView
        略……
     Caused by: android.view.InflateException: Binary XML file line #11 in net.swiftzer.etademo:layout/main_activity: Binary XML file line #11 in net.swiftzer.etademo:layout/main_activity: Error inflating class androidx.fragment.app.FragmentContainerView
     Caused by: android.view.InflateException: Binary XML file line #11 in net.swiftzer.etademo:layout/main_activity: Error inflating class androidx.fragment.app.FragmentContainerView
     Caused by: java.lang.RuntimeException: Exception inflating net.swiftzer.etademo:navigation/eta line 24
        at androidx.navigation.p.c(Unknown Source:119)
        at androidx.navigation.NavController.i(:2)
        at androidx.navigation.fragment.NavHostFragment.M(:43)
        略……
     Caused by: java.lang.RuntimeException: java.lang.ClassNotFoundException: net.swiftzer.etademo.common.Line
        at androidx.navigation.p.d(:1)
        at androidx.navigation.p.b(:1)

原因是我们那个 navigation graph 有引用到 net.swiftzer.etademo.common.Linenet.swiftzer.etademo.common.Station 两个 enum(就是那两个 <argument>)。R8 会把这些 class 混淆(重新命名),所以在启动时 Navigation component 会找不到这两个 class。解决方法是把这两个 enum 补上 @Keep annotation,这样 R8 就不会把那些 class 混淆。

@Keep
enum class Line(val zh: String, val en: String) {
    AEL("机场快綫", "Airport Express"),
    TCL("东涌綫", "Tung Chung Line"),
    TML("屯马綫", "Tuen Ma Line"),
    TKL("将军澳綫", "Tseung Kwan O Line"),
}

基本上我们凡是见到 XML 档有 class 的引用都应该要留是把那些 class 剔除在混淆范围之内。

小结

Navigation component 看似把以往处理一般转页和 deep link 的麻烦事变得更易管理,但它是不是真的那麽好用呢?当然不是!如果简单看过它的文档或许会觉得它很美好,但去到实际使用时就发觉有大大小小的问题,感觉它就是一个半制成品般。我强烈建议大家看看 Isaac Udy 演讲的 Navigation in multi-module projects, and the problem with AndroidX Navigation,里面有提及当 multi-module 时使用 Navigation component 所遇到的问题和解决方法,但那些解决方法都会令原本的 Navigation component 特色削弱(例如 type safe 的 parameter 变不 type safe),所以我才说 Navigation component 似是一个半制成品。


<<:  Day25- Go with MySQL

>>:  [Day 18] 今晚我想来点tinyML加Arm不加香菜

Day 16 储存宝石:S3 储存类别 & 生命周期管理

挑战赛过一半了!今天我们要介绍的是 AWS S3 的储存类别及生命周期管理。 AWS S3 五大储...

Day 8 (CSS)

1.transition-timing-function 使用 曲线设置 通常: #d2{ tran...

[Day_1] Python基础小教室

嗨~大家好, 接下来是为期一个月的铁人挑战 print('Day_1') 今天是想先跟大家做跟心灵喊...

springboot连rabbitMQ的简介

开一个 docker-compose.yml 填入 version: "3.5"...

作业系统 Critical section

记录学习内容。 以下内容大多引用大大们的文章,加上一些自己的笔记。 自己的笔记部分,内容可能有错误。...