THUInfo踩坑记录(一)
2020-02-13 / UNIDY

去年年底,我开始着手写THUInfo。经过一个多月的磕磕绊绊,我也总算是做出了一点东西——虽然我也知道,这距离一个成熟的APP还有很长一段距离,更何况我也只做出了Android版本。

眼瞅着就要进入常规的学习状态了,各门课的老师未见其人,已见其任务,THUInfo的开发可以暂缓一段时间了。回顾这一个多月,编程水平不见得有多少提升,奇怪的知识倒是增加了不少。我觉得自己有必要将一些内容记录下来,即使没人看,也算是帮助自己温故而知新吧。(实则突然找到可以写成博客的素材了)


我毕竟刚刚接触Android,很多认识也都停留在表面,没有太多接触源码的东西,因此我现阶段写下的内容,只管能用就行。

第一部分,我打算写点当前少有教程提到的内容:页面导航(Navigation),以及可配套食用的顶部工具栏(Toolbar)、底部导航栏(BottomNavigationView)和侧边抽屉(DrawerLayout)。

引入

Navigation能做什么?

我们经常需要处理页面之间的切换。THUInfo为例,主页、动态、计划之间的切换就是基本操作。幸而,Google于2018年推出Android Jetpack,其中的Navigation模块可以帮助开发者使用少量代码轻松地实现这一需求

此外,我们的需求往往不止于三四个根页面之间的切换。例如,在主页页面之下,我们还要有教室资源、消费查询等子页面。并且,我们还希望,在这些子页面中,返回键能够得到正确的处理(即回到上级页面),屏幕左上角也要相应地设置一个虚拟的返回按钮。而这些需求,借助Navigation,均可轻松得到实现。

那么,Navigation到底是怎么一回事呢?Navigation最基本的思路就是,设定一个容器fragment(注:新版Navigation推荐使用FragmentContainerView,不过我还没研究),将其标记为宿主(NavHostFragment),由NavController进行管理。实际运用时如要切换页面,只需替换掉该fragment填充的内容即可。此外,NavController切换页面时,会自动维护返回栈,从而可进一步实现返回键的处理。

从设计理念上讲,通过Navigation可以将不同的页面借助fragment进行关联,而无需创建更多的activity——这也符合了“一个APP只用一个activity”的理念。


快速入门

懒人模式

New Project中选择Bottom Navigation Activity作为模板就可以了。

不过,看例程有可能看得云里雾里的,而且如果想把它迁移到已有项目中也要费一番周折,所以下面我来对其中的关键步骤进行分解,谈谈如何具体操作。

自己动手

准备工作

添加Gradle依赖

1
2
3
4
5
6
7
8
9
10
11
dependencies {
...
// For Java
implementation 'androidx.navigation:navigation-fragment:2.2.1'
implementation 'androidx.navigation:navigation-ui:2.2.1'

// For Kotlin
implementation 'androidx.navigation:navigation-fragment-ktx:2.2.1'
implementation 'androidx.navigation:navigation-ui-ktx:2.2.1'
...
}

创建第一个页面

IDE为我们提供了图形化操作界面,可以帮助我们轻松创建一个页面。

res下新建navigation目录,右击,新建一个Navigation Resource Filerootnavigation),然后你应该会看见类似这样的代码:

1
2
3
4
5
6
<?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"
android:id="@+id/mobile_navigation">

</navigation>

接着,进入DesignSplit视图,你应该能看见Click [icon] to add a destination。点击它所指示的图标,你会看见Create new destination。点击,IDE会弹出New Android Component对话框。

Fragment Name一栏中,填好相应的名称,例如HomeFragment。假如命名合理规范,下面的Fragment Layout Name一栏也会相应地变化,例如变成fragment_home。接下来,Create layout XML?勾上,Include fragment factory methods?不用勾,这样后续可以省很多事。

创建完成后,这一个导航文件的内容也会发生一些变化。你可以对它进行一些调整,例如android:label表示该fragment显示给用户的名称,可以根据需要改成其它值。

除此之外,你应该能在res/layout目录下发现多了一个.xml布局文件,你可以稍后对它进行修改。

而在代码部分,你应该会发现,多出了一个继承自Fragment的自定义类(可能是HomeFragment)。这个类将会负责管理你所创建的这个页面的生命周期。比如,你会重载onStart方法,从而设定这个页面启动时的行为。


设置导航菜单

res下新建menu目录,在menu目录下创建一个bottom_nav_menu.xml(文件名随意,这里加bottom为了与之后的侧边抽屉区分)。里面的内容大概长这样:

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/homeFragment"
android:icon="@drawable/ic_home_black_24dp"
android:title="@string/title_home"/>
...
</menu>

我觉得已经非常直白了,就不再赘述了。

不过有一点,貌似这儿的id要和你在navigation文件中对应页面的id保持一致。


去掉默认标题栏

Google为Android应用提供了默认的标题栏,但实践表明,预设的标题栏太丑了,且难以定制。因此,我们先要将其去掉,以便后面设置更加灵活的Toolbar

方法很简单,找到res/values/styles.xml,将AppThemeparent改成Theme.AppCompat.Light.NoActionBar即可。

接着,为设置Toolbar稍作准备,设置一下Toolbar的主题样式:

1
2
3
4
5
6
7
8
9
<resources>
...
<style name="ToolbarTheme">
<item name="colorControlNormal">@android:color/white</item>
<item name="android:textColorPrimary">@android:color/white</item>
<item name="android:background">@color/colorPrimary</item>
</style>
...
</resources>

其中,colorControlNormalandroid:textColorPrimary分别是工具栏按钮和标题文字的颜色,至于是white还是black取决于背景色。


添加宿主容器

我们一般会选择一个“模板页面”,腾出一块地方安排宿主容器。例如,在THUInfo中,我就将宿主容器放到了主页面activity_main.xml中(注意不是“主页”,“主页”“动态”“计划”是平行的三个模块)。

你的布局文件大概会像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">

<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_constraintTop_toTopOf="parent"
android:theme="@style/ToolbarTheme"/>

<fragment
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="@dimen/activity_vertical_margin"
app:defaultNavHost="true"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintBottom_toTopOf="@id/bottom_nav_view"
app:navGraph="@navigation/mobile_navigation"/>

<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_nav_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/windowBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:menu="@menu/bottom_nav_menu"/>
</androidx.constraintlayout.widget.ConstraintLayout>

稍微提几点。

  • 第16行,fragmentid,会在之后指定NavController时用到。
  • 第17行,作用在于将该fragment标记为宿主。
  • 第19行,Google的例程有点问题,在ConstraintLayout下会自动填满整个屏幕。我把它改成了0dp,这样可以指定它竖直方向填充的上下限。
  • 第21行,将其设定为defaultNavHost,这样NavController在处理返回键时,就能知道,是在对这一个NavHost进行操作。
  • 第24行,指定该宿主执行相应navGraph中页面的切换。
  • 第32行,将menu设置为刚刚设好的导航菜单。

至此,准备工作已经就绪,下面就可以看代码了。


代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class MainActivity : AppCompatActivity() {

private lateinit var appBarConfiguration: AppBarConfiguration
private lateinit var navController: NavController
private lateinit var toolbar: Toolbar

private val topLevelDestinationIds = setOf(R.id.homeFragment, R.id.newsFragment, R.id.scheduleFragment)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

toolbar = findViewById(R.id.toolbar)
setSupportActionBar(toolbar)

navController = findNavController(R.id.nav_host_fragment)
appBarConfiguration = AppBarConfiguration(topLevelDestinationIds)
setupActionBarWithNavController(navController, appBarConfiguration)
findViewById<BottomNavigationView>(R.id.bottom_nav_view).setupWithNavController(navController)
}
}

(有机会整一个Kotlin的语法高亮……IDE里面五彩斑斓的,搬到网页上就全是黑的了……)

  • 第7行,将它们作为top level destinations拎出来,既能突出其特殊地位,也方便NavController进行处理。
  • 第13、14行,设置Toolbar
  • 第16行,获取NavController关于findNavController有一个注意点,后面再说。
  • 第17、18行,将NavController与标题栏(这里是toolbar)进行绑定。
  • 第19行,将NavController与底部导航栏进行绑定。

现在,假如一切顺利的话,程序应该就能跑起来了。


导航

讲到这里,我们已经可以在若干个top level destination之间进行切换了。不过,要在不同层级的页面之间进行切换,又该如何操作呢?

这就需要用到NavControllernavigate方法了。

navigate方法的签名如下:

1
public void navigate(int resId)

它的一个作用是,提供一个目标的resId,从而让该NavController实例切换至这个目标。

这里有两个问题:目标的resId如何提供,以及NavController实例如何找到。


目标的resId如何提供?

具体手法上与#创建第一个页面的操作类似,resId就是你所创建的对应页面的id,在此不再细讲。

在上面的示例代码中,我使用了findNavController方法来获取NavController实例。但仔细观察后,我们发现,这里的findNavControllerActivity类的一个拓展方法。那么,当我们已经在某一个fragment中时,又该如何获取到NavController实例呢?

这时,我们看到,NavHostFragment下也有一个findNavController方法。它的签名如下:

1
public static NavController findNavController(Fragment fragment)

具体而言,提供一个fragment,它会沿着这个fragmentparent chain,一直找到其对应的NavController。因此,在实际应用中,代码一般长这样:

1
2
3
wentu_btn.setOnClickListener {
NavHostFragment.findNavController(this).navigate(R.id.wentuFragment)
}

这里的this指代的是代码所处的HomeFragment类。

当然,方法不止这一种。我们同样可以用FragmentgetActivity方法,先获取到对应的Activity,再调用ActivityfindNavController方法。不过这样就需要在fragment中用到宿主fragmentid,我个人认为这样写不利于降低模块间的耦合度。


处理虚拟返回键

有了NavController,我们发现,物理的返回键已经能够得到正确的处理,即返回上级页面。然而,屏幕左上角的虚拟返回键却丝毫没有响应。这是为什么呢?

其实,这也不是什么怪事。NavController的文档中已经指出:

You are responsible for calling NavController.navigateUp to handle the Navigation button.
Typically this is done in AppCompatActivity.onSupportNavigateUp.

因此,我们需要自己重写onSupportNavigateUp方法。于是,我们回到MainActivity中,加上这样一行代码:

1
override fun onSupportNavigateUp() = navController.navigateUp(appBarConfiguration)

简简单单的一句话,就能顺利解决问题。我想,这背后的魔力,就在于NavController的各种优秀的方法(例如这里的navigateUp)。


侧边抽屉

这里,同样也有懒人模式……在New Project中找到Navigation Drawer Activity就好了。

不过,鉴于我们需要将抽屉直接引入当前的项目,我们最好还是手动进行整合。


调整布局

我们先来回顾一下THUInfo的抽屉。

THUInfo抽屉布局

有两个部分。上面是一个渐变色背景的headerLayout,下面是menu


headerLayout

如何画出一个渐变色的背景,并不是我们的重点。可以直接看Google的例程,也可以直接看THUI的源代码(前景背景)。

#设置导航菜单中的操作类似,不过要创建一个新的menu文件,比如说side_nav_menu.xml。而且还更简单,因为这里不需要设置icon了。


修改布局文件

这里,我们就要对activity_main.xml大动干戈了。

其实改动也不大,主要是在外面套一层DrawerLayout,再添加一个NavigationView

改完之后大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout
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/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:openDrawer="start">

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- Identical code omitted -->
</androidx.constraintlayout.widget.ConstraintLayout>

<com.google.android.material.navigation.NavigationView
android:id="@+id/side_nav_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
app:headerLayout="@layout/nav_header_main"
app:menu="@menu/side_nav_menu"/>

</androidx.drawerlayout.widget.DrawerLayout>

基本上还是比较直白的,没有多少可说的。有一点,就是Google的例程上有两处android:fitsSystemWindows="true"。有没有这句话的区别,这里主要体现在,程序进行绘制时是否算进系统顶部的透明状态栏。


修改源代码

下面,就是修改源代码了。主要有两处要改。

第一处,是将appBarConfiguration = AppBarConfiguration(topLevelDestinationIds)改成appBarConfiguration = AppBarConfiguration(topLevelDestinationIds, findViewById(R.id.drawer_layout)),从而将这一DrawerLayout绑定进去。

第二处,是要处理抽屉里的选项的点击指令。这里,我通过重写onCreateOptionsMenu方法来实现。(这一块我也没弄清楚为什么要在这里重载。)

具体而言:

1
2
3
4
5
6
7
8
9
override fun onCreateOptionsMenu(menu: Menu): Boolean {
side_nav_view.setNavigationItemSelectedListener {
when (it.itemId) {
// Do something
}
true
}
return true
}

// Do something的部分对点击的选项进行处理。其中,itemId为刚刚添加的menu的项目对应的id


到此为止,运行程序,我们已经可以通过Navigation实现页面之间的切换,并能够配合ToolbarBottomNavigationViewDrawerLayout一起食用。最后,我想关于定制Toolbar做一点补充。

定制Toolbar

在实际应用中,我们有时会希望Toolbar的标题文字和按钮图标能够动态地改变。这又该如何实现呢?


动态设置标题文字

THUInfo为例,在查询教室资源时,当用户进入了具体的教学楼页面(例如“六教”),标题栏也相应地变成了“六教”。代码其实很简单:

1
(activity as? AppCompatActivity)?.supportActionBar?.title = "{Your Title}"

此处,先获得fragment所在的activity,并将其转化为AppCompatActivity,因为getSupportActionBarAppCompatActivity的方法。得到了supportActionBar后,获取到title,我们就可以动态地进行设置了。


动态设置按钮图标

THUInfo中,当收到新邮件时,左上角的按钮图标会变成带小红点的三道杠。这又是如何实现的呢?

首先,我们当然是要先准备好新图标。这里比较重要的是,图标的大小要调整好,比如设置成24dp*24dp,因为Toolbar貌似不会对图标进行自动缩放。

代码跟上面差不多,得到supportActionBar之后,设置navigationIcon属性,即可实现。

当然,这有点暴力修改的意味在里面。因此,要想显得不那么暴力,需要考虑一些问题。最主要的,就是设置navigationIcon的过程是一瞬间完成的,中间的过渡动画就没有了。

还有两个小问题。

  1. 每30秒重新获取一次未读邮件情况,相应地也要刷新一下左上角的图标。然而,**我忘了还要判断当前页面是否为top level destiniation**,导致的结果是,明明左上角应该是一个返回键,突然就变成带小红点的三道杠了。
    看一下修改之后的代码片段:

    1
    2
    3
    if (navController.currentDestination?.id in topLevelDestinationIds) {
    // Update badge
    }
  2. 然而,仅仅每30秒刷新一次显然不够——当用户重新来到top level destination时,也应刷新一次。这时,我们就要监听NavController的目标变化了:

    1
    2
    3
    navController.addOnDestinationChangedListener { _, destination, _ ->
    if (destination.id in topLevelDestinationIds) refreshBadge()
    }

(所以,为什么不自己做一个Toolbar呢)

THUInfo中处理小红点的详细代码MainActivity.kt中。


好了,感觉自己也写了不少了,也算是把整个Navigation的来龙去脉都简单地梳理了一遍。

既然……我也不太会写结束语,那就,先这样结束吧~

本文链接:https://www.unidy.cn/articles/thui-a-1/