手机平板要兼顾,探究 Fragment
在移动设备发展非常迅速的时代,平板也变得越来越普及。平板和手机最大的区别就在于屏幕的大小,屏幕大小差距过大有可能会让同样的界面在视觉效果上有较大的差异,比如一些界面在手机上看起来非常美观,但在平板上看起来可能会有控件被过分拉长、元素之间空隙过大等情况。
所以对于一名专业的 Android 开发人员而言,能够兼顾手机和平板的开发是我们尽可能要做到的事情。Android 自 3.0 版本开始引入了 Fragment 的概念,它可以让界面在平板上更好地展示,下面我们就一起来学习一下。
Fragment 是什么
Fragment 是一种可以嵌入在 Activity 当中的 UI 片段,它能让程序更加合理和充分地利用大屏幕的空间,因而在平板上应用得非常广泛。Fragment 和 Activity 非常相像,同样都能包含布局,同样都有自己的生命周期。你甚至可以将 Fragment 理解成一个迷你型的 Activity,虽然这个迷你型的 Activity 有可能和普通的 Activity 是一样大的。
那么究竟要如何使用 Fragment 才能充分地利用平板屏幕的空间呢?想象我们正在开发一个新闻应用,其中一个界面使用 RecyclerView 展示了一组新闻的标题,当点击其中一个标题时,就打开另一个界面显示新闻的详细内容。如果是在手机中设计,我们可以将新闻标题列表放在一个 Activity 中,将新闻的详细内容放在另一个 Activity 中,设计如下图所示:
可是如果在平板上也这么设计,那么新闻标题列表将会被拉长至填充满整个平板的屏幕,而新闻的标题一般不会太长,这样将会导致界面上有大量的空白区域,因此,更好的设计方案是将新闻标题列表界面和新闻详细内容界面分别放在两个 Fragment中,然后在同一个 Activity 里引入这两个 Fragment,这样就可以将屏幕空间充分地利用起来了,设计如下图所示:
Fragment 的使用方式
介绍了这么多抽象的东西,是时候学习一下 Fragment 的具体用法了。首先我们要创建一个平板模拟器,可以使用 Android Studio 自带的也可以用第三方的(比如夜神模拟器),好了,准备工作都完成了,接着新建一个 FragmentTest 项目,然后开始我们的 Fragment 探索之旅吧。
Fragment 的简单用法
这里我们准备先写一个最简单的 Fragment 示例来练练手。在一个 Activity 当中添加两个 Fragment,并让这两个 Fragment 平分 Activity 的空间。
新建一个左侧 Fragment 的布局 left_fragment.xml,代码如下所示:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:text="Button" />
</LinearLayout>
复制代码
这个布局非常简单,只放置了一个按钮,并让它水平居中显示。
然后新建右侧 Fragment 的布局 right_fragment.xml,代码如下所示:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#00ff00"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:text="This is right fragment"
android:textSize="24sp" />
</LinearLayout>
复制代码
可以看到,我们将这个布局的背景色设置成了绿色,并放置了一个 TextView 用于显示一段文本.
接着新建一个 LeftFragment 类,并让它继承自 Fragment,具体代码如下所示:
class LeftFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.left_fragment, container, false)
}
}
复制代码
这里仅仅是重写了 Fragment 的 onCreateView() 方法,然后在这个方法中通过 LayoutInflater 的 inflate() 方法将刚才定义的 left_fragment 布局动态加载进来,整个方法简单明了。接着我们用同样的方法再新建一个 RightFragment,代码如下所示:
class RightFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.right_fragment, container, false)
}
}
复制代码
代码基本上是相同的,相信已经没有必要再做什么解释了。接下来修改 activity_main.xml 中的代码,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<fragment
android:id="@+id/leftFrag"
android:name="com.example.fragmenttest.LeftFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<fragment
android:id="@+id/rightFrag"
android:name="com.example.fragmenttest.RightFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
</LinearLayout>
复制代码
可以看到,我们使用了 标签在布局中添加 Fragment,其中指定的大多数属性你已经非常熟悉了,只不过这里还需要通过 android:name 属性来显式声明要添加的 Fragment 类名,注意一定要将类的包名也加上。
这样最简单的 Fragment 示例就已经写好了,现在运行一下程序,效果如下图所示:
正如我们预期的一样,两个 Fragment 平分了整个 Activity 的布局。不过这个例子实在是太简单了,在真正的项目中很难有什么实际的作用,因此下面我们马上来看一看,关于 Fragment 更加高级的使用技巧。
动态添加 Fragment
通过上面的例子,我们已经学会了在布局文件中添加 Fragment 的方法,不过 Fragment 真正的强大之处在于,它可以在程序运行时动态地添加到 Activity 当中。根据具体情况来动态地添加 Fragment,你就可以将程序界面定制得更加多样化。
我们在上一个例子的代码的基础上继续完善,新建 another_right_fragment.xml,代码如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#ffff00"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:text="This is another right fragment"
android:textSize="24sp" />
</LinearLayout>
复制代码
这个布局文件的代码和 right_fragment.xml 中的代码基本相同,只是将背景色改成了黄色,并将显示的文字改了改。然后新建 AnotherRightFragment 作为另一个右侧 Fragment,代码如下所示:
class AnotherRightFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.another_right_fragment, container, false)
}
}
复制代码
代码同样非常简单,在 onCreateView() 方法中加载了刚刚创建的 another_right_fragment 布局。这样我们就准备好了另一个 Fragment,接下来看一下如何将它动态地添加到 Activity 当中。修改 activity_main.xml,代码如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<fragment
android:id="@+id/leftFrag"
android:name="com.example.fragmenttest.LeftFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
android:id="@+id/rightLayout"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"/>
</LinearLayout>
复制代码
可以看到,现在将右侧 Fragment 替换成了一个 FrameLayout。这是 Android 中最简单的一种布局,所有的控件默认都会摆放在布局的左上角。由于这里仅需要在布局里放入一个 Fragment,不需要任何定位,因此非常适合使用 FrameLayout。
下面我们将在代码中向 FrameLayout 里添加内容,从而实现动态添加 Fragment 的功能。修改 MainActivity 中的代码,如下所示:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
button.setOnClickListener {
replaceFragment(AnotherRightFragment())
}
replaceFragment(RightFragment())
}
private fun replaceFragment(fragment: Fragment) {
val fragmentManager = supportFragmentManager
val transaction = fragmentManager.beginTransaction()
transaction.replace(R.id.rightLayout, fragment)
transaction.commit()
}
}
复制代码
可以看到,首先我们给左侧 Fragment 中的按钮注册了一个点击事件,然后调用 replaceFragment() 方法动态添加了 RightFragment。当点击左侧 Fragment 中的按钮时,又会调用 replaceFragment() 方法,将右侧 Fragment 替换成 AnotherRightFragment。结合 replaceFragment() 方法中的代码可以看出,动态添加 Fragment 主要分为 5 步:
- 创建待添加 Fragment 的实例。
- 获取 FragmentManager,在 Activity 中可以直接调用 getSupportFragmentManager() 方法获取。
- 开启一个事务,通过调用beginTransaction()方法开启。
- 向容器内添加或替换 Fragment,一般使用 replace() 方法实现,需要传入容器的 id 和待添加的 Fragment 实例。
- 提交事务,调用 commit() 方法来完成。
这样就完成了在 Activity 中动态添加 Fragment 的功能,重新运行程序,可以看到和之前相同的界面,然后点击一下按钮,效果如下图所示。
在 Fragment 中实现返回栈
在上面的例子中我们成功实现了向 Activity 中动态添加 Fragment 的功能。不过你尝试一下就会发现,通过点击按钮添加了一个 Fragment 之后,这时按下 Back 键程序就会直接退出。如果我们想实现类似于返回栈的效果,按下 Back 键可以回到上一个 Fragment,该如何实现呢?
实很简单,FragmentTransaction 中提供了一个 addToBackStack() 方法,可以用于将一个事务添加到返回栈中。修改 MainActivity 中的代码,如下所示:
class MainActivity : AppCompatActivity() {
...
private fun replaceFragment(fragment: Fragment) {
val fragmentManager = supportFragmentManager
val transaction = fragmentManager.beginTransaction()
transaction.replace(R.id.rightLayout, fragment)
transaction.addToBackStack(null)
transaction.commit()
}
}
复制代码
这里我们在事务提交之前调用了 FragmentTransaction 的 addToBackStack() 方法,它可以接收一个名字用于描述返回栈的状态,一般传入 null 即可。现在重新运行程序,并点击按钮将 AnotherRightFragment 添加到 Activity 中,然后按下 Back 键,你会发现程序并没有退出,而是回到了 RightFragment 界面。继续按下 Back 键,RightFragment 界面也会消失,再次按下 Back 键,程序才会退出。
Fragment 和 Activity 之间的交互
虽然 Fragment 是嵌入在 Activity 中显示的,可是它们的关系并没有那么亲密。实际上,Fragment 和 Activity 是各自存在于一个独立的类当中的,它们之间并没有那么明显的方式来直接进行交互。如果想要在Activity 中调用 Fragment 里的方法,或者在 Fragment 中调用 Activity 里的方法,应该如何实现呢?
为了方便 Fragment 和 Activity 之间进行交互,FragmentManager 提供了一个类似于 findViewById() 的方法,专门用于从布局文件中获取 Fragment 的实例,代码如下所示:
val fragment = supportFragmentManager.findFragmentById(R.id.leftFrag) as LeftFragment
复制代码
调用 FragmentManager 的 findFragmentById() 方法,可以在 Activity 中得到相应 Fragment 的实例,然后就能轻松地调用 Fragment 里的方法了。
另外,类似于 findViewById() 方法,kotlin-android-extensions 插件也对 findFragmentById() 方法进行了扩展,允许我们直接使用布局文件中定义的 Fragment id 名称来自动获取相应的 Fragment实例,如下所示:
val fragment = leftFrag as LeftFragment
复制代码
掌握了如何在 Activity 中调用 Fragment 里的方法,那么在 Fragment 中又该怎样调用 Activity 里的方法呢?这就更简单了,在每个 Fragment 中都可以通过调用 getActivity() 方法来得到和当前 Fragment 相关联的 Activity 实例,代码如下所示:
if (activity != null) {
val mainActivity = activity as MainActivity
}
复制代码
这里由于 getActivity() 方法有可能返回 null,因此我们需要先进行一个判空处理。有了 Activity 的实例,在 Fragment 中调用 Activity 里的方法就变得轻而易举了。另外当 Fragment 中需要使用 Context 对象时,也可以使用 getActivity() 方法,因为获取到的 Activity 本身就是一个 Context 对象。
这时不知道你心中会不会产生一个疑问:既然 Fragment 和 Activity 之间的通信问题已经解决了,那么不同的 Fragment 之间可不可以进行通信呢?
说实在的,这个问题并没有看上去那么复杂,它的基本思路非常简单:首先在一个 Fragment 中可以得到与它相关联的 Activity,然后再通过这个 Activity 去获取另外一个 Fragment 的实例,这样就实现了不同 Fragment 之间的通信功能。
Fragment 的生命周期
和 Activity 一样,Fragment 也有自己的生命周期,并且它和 Activity 的生命周期实在是太像了,下面我们马上就来看一下。
Fragment 的状态和回调
还记得每个 Activity 在其生命周期内可能会有哪几种状态吗?没错,一共有运行状态、暂停状态、停止状态和销毁状态这 4 种。类似地,每个 Fragment 在其生命周期内也可能会经历这几种状态,只不过在一些细小的地方会有部分区别。
-
运行状态
当一个 Fragment 所关联的 Activity 正处于运行状态时,该 Fragment 也处于运行状态。
-
暂停状态
当一个 Activity 进入暂停状态时(由于另一个未占满屏幕的 Activity 被添加到了栈顶),与它相关联的 Fragment 就会进入暂停状态。
-
停止状态
当一个 Activity 进入停止状态时,与它相关联的 Fragment 就会进入停止状态,或者通过调用 FragmentTransaction 的 remove()、replace() 方法将 Fragment 从 Activity 中移除,但在事务提交之前调用了 addToBackStack() 方法,这时的 Fragment 也会进入停止状态。总的来说,进入停止状态的 Fragment 对用户来说是完全不可见的,有可能会被系统回收。
-
销毁状态
Fragment 总是依附于 Activity 而存在,因此当 Activity 被销毁时,与它相关联的 Fragment 就会进入销毁状态。或者通过调用 FragmentTransaction 的 remove()、replace() 方法将 Fragment 从 Activity 中移除,但在事务提交之前并没有调用 addToBackStack() 方法,这时的 Fragment 也会进入销毁状态。
同样地,Fragment 类中也提供了一系列的回调方法,以覆盖它生命周期的每个环节。其中,Activity 中有的回调方法,Fragment 中基本上也有,不过 Fragment 还提供了一些附加的回调方法,下面我们就重点看一下这几个回调。
- onAttach():当 Fragment 和 Activity 建立关联时调用。
- onCreateView():为 Fragment 创建视图(加载布局)时调用。
- onActivityCreated():确保与 Fragment 相关联的 Activity 已经创建完毕时调用。
- onDestroyView():当与 Fragment 关联的视图被移除时调用。
- onDetach():当 Fragment 和 Activity 解除关联时调用。
体验 Fragment 的生命周期
为了能够更加直观地体验 Fragment 的生命周期,我们还是通过一个例子来实践一下。例子很简单,仍然是在 FragmentTest 项目的基础上改动的。
修改 RightFragment 中的代码,如下所示:
class RightFragment : Fragment() {
companion object {
const val TAG = "RightFragment"
}
override fun onAttach(context: Context) {
super.onAttach(context)
Log.d(TAG, "onAttach")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate")
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
Log.d(TAG, "onCreateView")
return inflater.inflate(R.layout.right_fragment, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
Log.d(TAG, "onActivityCreated")
}
override fun onStart() {
super.onStart()
Log.d(TAG, "onStart")
}
override fun onResume() {
super.onResume()
Log.d(TAG, "onResume")
}
override fun onPause() {
super.onPause()
Log.d(TAG, "onPause")
}
override fun onStop() {
super.onStop()
Log.d(TAG, "onStop")
}
override fun onDestroyView() {
super.onDestroyView()
Log.d(TAG, "onDestroyView")
}
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "onDestroy")
}
override fun onDetach() {
super.onDetach()
Log.d(TAG, "onDetach")
}
}
复制代码
注意,这里为了方便日志打印,我们先定义了一个 TAG 常量。Kotlin 中定义常量都是使用的这种方式,在 companion object、单例类或顶层作用域中使用 const 关键字声明一个变量即可。
接下来,我们在 RightFragment 中的每一个回调方法里都加入了打印日志的代码,然后重新运行程序。这时观察 Logcat 中的打印信息,如下图所示。
可以看到,当 RightFragment 第一次被加载到屏幕上时,会依次执行 onAttach()、onCreate()、onCreateView()、onActivityCreated()、onStart() 和 onResume() 方法。然后点击 LeftFragment 中的按钮,此时打印信息如下图所示:
由于 AnotherRightFragment 替换了 RightFragment,此时的 RightFragment 进入了停止状态,因此onPause()、onStop() 和 onDestroyView() 方法会得到执行。当然,如果在替换的时候没有调用 addToBackStack() 方法,此时的 RightFragment 就会进入销毁状态,onDestroy() 和 onDetach() 方法就会得到执行。
接着按下 Back 键,RightFragment 会重新回到屏幕,打印信息如下图所示:
由于 RightFragment 重新回到了运行状态,因此 onCreateView()、onActivityCreated()、onStart() 和 onResume() 方法会得到执行。注意,此时 onCreate() 方法并不会执行,因为我们借助了 addToBackStack() 方法使得 RightFragment 并没有被销毁。
现在再次按下 Back 键,打印信息如下图所示:
依次执行 onPause()、onStop()、onDestroyView()、onDestroy() 和 onDetach() 方法,最终将 Fragment 销毁。
另外值得一提的是,在 Fragment 中你也可以通过 onSaveInstanceState() 方法来保存数据,因为进入停止状态的 Fragment 有可能在系统内存不足的时候被回收。保存下来的数据在 onCreate()、onCreateView() 和 onActivityCreated() 这 3 个方法中你都可以重新得到,它们都含有一个 Bundle 类型的 savedInstanceState 参数。
动态加载布局的技巧
虽然动态添加 Fragment 的功能很强大,可以解决很多实际开发中的问题,但是它毕竟只是在一个布局文件中进行一些添加和替换操作。如果程序能够根据设备的分辨率或屏幕大小,在运行时决定加载哪个布局,那我们可发挥的空间就更多了。因此本节我们就来探讨一下 Android 中动态加载布局的技巧。
使用限定符
我们可以发现在平板模式下很多应用采用的都是双页模式,因为平板的屏幕足够大,完全可以同时显示两页的内容,但手机的屏幕就只能显示一页的内容,因此两个页面需要分开显示。
那么怎样才能在运行时判断程序应该是使用双页模式还是单页模式呢?这就需要借助限定符(qualifier)来实现了。下面我们通过一个例子来学习一下它的用法,修改 FragmentTest 项目中的 activity_main.xml 文件,代码如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<fragment
android:id="@+id/leftFrag"
android:name="com.example.fragmenttest.LeftFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
复制代码
这里将多余的代码删掉,只留下一个左侧 Fragment,并让它充满整个父布局。接着在 res 目录下新建 layout-large 文件夹,在这个文件夹下新建一个布局,也叫作 activity_main.xml,代码如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<fragment
android:id="@+id/leftFrag"
android:name="com.example.fragmenttest.LeftFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<fragment
android:id="@+id/rightFrag"
android:name="com.example.fragmenttest.RightFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3" />
</LinearLayout>
复制代码
可以看到,layout/activity_main 布局只包含了一个 Fragment,即单页模式,而 layout-large/activity_main 布局包含了两个 Fragment,即双页模式。其中,large 就是一个限定符,那些屏幕被认为是 large 的设备就会自动加载 layout-large 文件夹下的布局,小屏幕的设备则还是会加载 layout 文件夹下的布局。
然后将 MainActivity 中 replaceFragment() 方法里的代码注释掉,并在平板模拟器上重新运行程序,效果如下图所示:
再启动一个手机模拟器,并重新运行程序,效果如下图所示:
使用最小宽度限定符
前面我们使用 large 限定符成功解决了单页双页的判断问题,不过很快又有一个新的问题出现了:large 到底是指多大呢?有时候我们希望可以更加灵活地为不同设备加载布局,不管它们是不是被系统认定为 large,这时就可以使用最小宽度限定符(smallest-width qualifier)。
最小宽度限定符允许我们对屏幕的宽度指定一个最小值(以 dp 为单位),然后以这个最小值为临界点,屏幕宽度大于这个值的设备就加载一个布局,屏幕宽度小于这个值的设备就加载另一个布局。
在 res 目录下新建 layout-sw600dp 文件夹,然后在这个文件夹下新建 activity_main.xml 布局,代码如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<fragment
android:id="@+id/leftFrag"
android:name="com.example.fragmenttest.LeftFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<fragment
android:id="@+id/rightFrag"
android:name="com.example.fragmenttest.RightFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3" />
</LinearLayout>
复制代码
这就意味着,当程序运行在屏幕宽度大于等于 600 dp 的设备上时,会加载 layout-sw600dp/activity_main 布局,当程序运行在屏幕宽度小于 600 dp 的设备上时,则仍然加载默认的 layout/activity_main 布局。
Fragment 的最佳实践:一个简易版的新闻应用
我们已经将关 于Fragment 的重要知识点掌握得差不多了,不过在灵活运用方面可能还有些欠缺,因此下面我们来通过 Fragment 实现一个简易版的新闻应用。
前面提到过,Fragment 很多时候是在平板开发当中使用的,因为它可以解决屏幕空间不能充分利用的问题。那是不是就表明,我们开发的程序都需要提供一个手机版和一个平板版呢?确实有不少公司是这么做的,但是这样会耗费很多的人力物力财力。因为维护两个版本的代码成本很高:每当增加新功能时,需要在两份代码里各写一遍;每当发现一个 bug 时,需要在两份代码里各修改一次。因此,今天我们最佳实践的内容就是教你如何编写兼容手机和平板的应用程序。
还记得我们在开始的时候提到的一个新闻应用吗?现在我们就运用本章所学的知识来编写一个简易版的新闻应用,并且要求它可以兼容手机和平板。新建好一个 FragmentBestPractice 项目,然后开始动手吧!
首先新建一个新闻实体类 News,代码如下所示:
class News(val title: String, val content: String)
复制代码
News 类的代码非常简单,title 字段表示新闻标题,content 字段表示新闻内容。接着新建布局文件 news_content_frag.xml,作为新闻内容的布局:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/contentLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="invisible">
<TextView
android:id="@+id/newsTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="10dp"
android:textSize="20sp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#000" />
<TextView
android:id="@+id/newsContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:padding="15dp"
android:textSize="18sp" />
</LinearLayout>
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:layout_alignParentLeft="true"
android:background="#000" />
</RelativeLayout>
复制代码
新闻内容的布局主要可以分为两个部分:头部部分显示新闻标题,正文部分显示新闻内容,中间使用一条水平方向的细线分隔开。除此之外,这里还使用了一条垂直方向的细线,它的作用是在双页模式时将左侧的新闻列表和右侧的新闻内容分隔开。
另外,我们还要将新闻内容的布局设置成不可见。因为在双页模式下,如果还没有选中新闻列表中的任何一条新闻,是不应该显示新闻内容布局的。
接下来新建一个 NewsContentFragment 类,继承自 Fragment,代码如下所示:
class NewsContentFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.news_content_frag, container, false)
}
fun refresh(title: String, content: String) {
contentLayout.visibility = View.VISIBLE
newsTitle.text = title
newsContent.text = content
}
}
复制代码
这里首先在 onCreateView() 方法中加载了我们刚刚创建的 news_content_frag 布局,这个没什么好解释的。接下来又提供了一个 refresh() 方法,用于将新闻的标题和内容显示在我们刚刚定义的界面上。注意,当调用了 refresh() 方法时,需要将我们刚才隐藏的新闻内容布局设置成可见。
这样我们就把新闻内容的 Fragment 和布局都创建好了,但是它们都是在双页模式中使用的,如果想在单页模式中使用的话,我们还需要再创建一个 Activity NewsContentActivity,布局名就使用默认 activity_news_content 即可。然后修改 activity_news_content.xml 中的代码,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<fragment
android:id="@+id/newsContentFrag"
android:name="com.example.fragmentbestpractice.NewsContentFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
复制代码
这里我们充分发挥了代码的复用性,直接在布局中引入了 NewsContentFragment。这样相当于把 news_content_frag 布局的内容自动加了进来。
然后修改 NewsContentActivity 中的代码,如下所示:
class NewsContentActivity : AppCompatActivity() {
companion object {
fun actionStart(context: Context, title: String, content: String) {
val intent = Intent(context, NewsContentActivity::class.java).apply {
putExtra("title", title)
putExtra("content", content)
}
context.startActivity(intent)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_news_content)
val title = intent.getStringExtra("title")
val content = intent.getStringExtra("content")
if (title != null && content != null) {
val fragment = newsContentFrag as NewsContentFragment
fragment.refresh(title, content)
}
}
}
复制代码
可以看到,在 onCreate() 方法中我们通过 Intent 获取到了传入的新闻标题和新闻内容,然后获取 NewsContentFragment 的实例,接着调用它的 refresh() 方法,将新闻的标题和内容传入,就可以把这些数据显示出来了。注意,这里我们还提供了一个 actionStart() 方法来启动 NewsContentActivity,这种做法在 Activity 那章已经详细的讲过原因了。
接下来还需要再创建一个用于显示新闻列表的布局,新建 news_title_frag.xml,代码如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/newsTitleRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
复制代码
这个布局的代码就非常简单了,里面只有一个用于显示新闻列表的 RecyclerView。既然要用到 RecyclerView,那么就必定少不了子项的布局。新建 news_item.xml 作为 RecyclerView 子项的布局,代码如下所示:
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/newsTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:paddingLeft="10dp"
android:paddingTop="15dp"
android:paddingRight="10dp"
android:paddingBottom="15dp"
android:textSize="18sp" />
复制代码
子项的布局也非常简单,只有一个TextView。
既然新闻列表和子项的布局都已经创建好了,那么接下来我们就需要一个用于展示新闻列表的地方。这里新建 NewsTitleFragment 作为展示新闻列表的 Fragment,代码如下所示:
class NewsTitleFragment : Fragment() {
private var isTwoPane = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.news_title_frag, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
isTwoPane = activity?.findViewById<View>(R.id.newsContentLayout) != null
}
}
复制代码
可以看到,NewsTitleFragment 中并没有多少代码,在 onCreateView() 方法中加载了 news_title_frag 布局,这个没什么好说的。我们注意看一下 onActivityCreated() 方法,这个方法通过在 Activity 中能否找到一个 id 为 newsContentLayout 的 View,来判断当前是双页模式还是单页模式,因此我们需要让这个 id 为 newsContentLayout 的 View 只在双页模式中才会出现。
那么怎样才能实现让 id 为 newsContentLayout 的 View 只在双页模式中才会出现呢?其实并不复杂,只需要借助我们刚刚学过的限定符就可以了。首先修改 activity_main.xml 中的代码,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/newsTitleLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
android:id="@+id/newsTitleFrag"
android:name="com.example.fragmentbestpractice.NewsTitleFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
复制代码
上述代码表示在单页模式下只会加载一个新闻标题的 Fragment。
然后新建 layout-sw600dp 文件夹,在这个文件夹下再新建一个 activity_main.xml 文件,代码如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<fragment
android:id="@+id/newsTitleFrag"
android:name="com.example.fragmentbestpractice.NewsTitleFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
android:id="@+id/newsContentLayout"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3">
<fragment
android:id="@+id/newsContentFrag"
android:name="com.example.fragmentbestpractice.NewsContentFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
</LinearLayout>
复制代码
可以看出,在双页模式下,我们同时引入了两个 Fragment,并将新闻内容的 Fragment 放在了一个 FrameLayout 布局下,而这个布局的 id 正是 newsContentLayout。因此,能够找到这个 id 的时候就是双页模式,否则就是单页模式。
现在我们已经将绝大部分的工作完成了,但还剩下至关重要的一点,就是在 NewsTitleFragment 中通过 RecyclerView 将新闻列表展示出来。我们在 NewsTitleFragment 中新建一个内部类 NewsAdapter 来作为 RecyclerView 的适配器,如下所示:
class NewsTitleFragment : Fragment() {
private var isTwoPane = false
...
inner class NewsAdapter(private val newsList: List<News>) : RecyclerView.Adapter<NewsAdapter.ViewHolder>() {
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val newsTitle: TextView = view.newsTitle
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.news_item, parent, false)
val holder = ViewHolder(view)
holder.itemView.setOnClickListener {
// 如果是双页模式,则刷新NewsContentFragment中的内容
val news = newsList[holder.adapterPosition]
if (isTwoPane) {
val fragment = newsContentFrag as NewsContentFragment
fragment.refresh(news.title, news.content)
} else {
// 如果是单页模式,则直接启动NewsContentActivity
NewsContentActivity.actionStart(parent.context, news.title, news.content)
}
}
return holder
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val news = newsList[position]
holder.newsTitle.text = news.title
}
override fun getItemCount() = newsList.size
}
}
复制代码
RecyclerView 的用法我们已经相当熟悉了,因此这个适配器的代码是没有什么难度吧?需要注意的是,之前我们都是将适配器写成一个独立的类,其实也可以写成内部类。这里写成内部类的好处就是可以直接访问 NewsTitleFragment 的变量,比如 isTwoPane。
观察一下 onCreateViewHolder() 方法中注册的点击事件,首先获取了点击项的 News 实例,然后通过 isTwoPane 变量判断当前是单页还是双页模式。如果是单页模式,就启动一个新的 Activity 去显示新闻内容;如果是双页模式,就更新 NewsContentFragment 里的数据。
现在还剩最后一步收尾工作,就是向 RecyclerView 中填充数据了。修改 NewsTitleFragment 中的代码,如下所示:
class NewsTitleFragment : Fragment() {
private var isTwoPane = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.news_title_frag, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
isTwoPane = activity?.findViewById<View>(R.id.newsContentLayout) != null
val layoutManager = LinearLayoutManager(activity)
newsTitleRecyclerView.layoutManager = layoutManager
val adapter = NewsAdapter(getNews())
newsTitleRecyclerView.adapter = adapter
}
private fun getNews(): List<News> {
val newsList = ArrayList<News>()
for (i in 1..50) {
val news = News("This is news title $i", getRandomLengthString("This is news content $i. "))
newsList.add(news)
}
return newsList
}
private fun getRandomLengthString(str: String): String {
val n = (1..20).random()
val builder = StringBuilder()
repeat(n) {
builder.append(str)
}
return builder.toString()
}
inner class NewsAdapter(private val newsList: List<News>) : RecyclerView.Adapter<NewsAdapter.ViewHolder>() {
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val newsTitle: TextView = view.newsTitle
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.news_item, parent, false)
val holder = ViewHolder(view)
holder.itemView.setOnClickListener {
// 如果是双页模式,则刷新NewsContentFragment中的内容
val news = newsList[holder.adapterPosition]
if (isTwoPane) {
val fragment = newsContentFrag as NewsContentFragment
fragment.refresh(news.title, news.content)
} else {
// 如果是单页模式,则直接启动NewsContentActivity
NewsContentActivity.actionStart(parent.context, news.title, news.content)
}
}
return holder
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val news = newsList[position]
holder.newsTitle.text = news.title
}
override fun getItemCount() = newsList.size
}
}
复制代码
可以看到,onActivityCreated() 方法中添加了 RecyclerView 标准的使用方法。在 Fragment 中使用 RecyclerView 和在 Activity 中使用几乎是一模一样的,相信没有什么需要解释的。另外,这里调用了 getNews() 方法来初始化 50 条模拟新闻数据,同样使用了一个 getRandomLengthString() 方法来随机生成新闻内容的长度,以保证每条新闻的内容差距比较大。
这样我们所有的编码工作就已经完成了,赶快来运行一下吧!首先在手机模拟器上运行,效果如下图所示:
接下来将程序在平板模拟器上运行,效果如下图所示:
同样的一份代码,在手机和平板上运行却得到两种完全不同的效果,这说明我们程序的兼容性已经相当不错了。通过这个例子,我们对 Fragment 的理解一定又加深了许多。
小结
在最佳实践环节中,我们开发的新闻应用的代码复杂度还是有点高的。比起只需要兼容一个终端的应用,我们要考虑的东西多了很多。
下面我们来回顾一下本章所学的内容吧。首先你了解了 Fragment 的基本概念和使用场景,然后通过几个实例掌握了 Fragment 的常见用法,随后学习了 Fragment 生命周期的相关内容以及动态加载布局的技巧,最后在本章的最佳实践部分将前面所学的内容综合运用了一遍,相信你已经将 Fragment 相关的知识点都牢记在心,并可以较为熟练地应用了。