fragment [ˈfræɡmənt; fræɡˈment] 碎片;片段;残存部分
1 Fragment是什么Fragment是一种可以嵌入在Activity当中的UI片段,它能让程序更加合理和充分地利用大屏幕的空间,因而在平板上应用得非常广泛。比如下面的平板的双页设计:
2 Fragment的使用方式 2.1 Fragment的简单用法这里在一个Activity当中添加两个Fragment,并让这两个Fragment平分Activity的空间。
新建一个左侧Fragment的布局left_fragment.xml,代码如下所示:
这个布局非常简单,只放置了一个按钮,并让它水平居中显示。
然后新建右侧Fragment的布局right_fragment.xml,代码如下所示:
将这个布局的背景色设置成了绿色,并放置了一个TextView用于显示一段文 本。
接着新建一个LeftFragment类,并让它继承自Fragment。注意,这里可能会有两个不同包下的Fragment供你选择:一个是系统内置的android.app.Fragment,一个是AndroidX库中的androidx.fragment.app.Fragment。这里请一定要使用AndroidX库中的Fragment,因为它可以让Fragment的特性在所有Android系统版本中保持一致,而系统内置的Fragment在Android 9.0版本中已被废弃。使用AndroidX库中的Fragment并不需要在build.gradle文件中添加额外的依赖,只要在创建新项目时勾选了Use androidx.* artifacts选项,Android Studio会自动帮你导入必要的AndroidX库。
现在编写一下LeftFragment中的代码,如下所示:
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中的代码,如下所示:
这里使用了
这样最简单的Fragment示例就已经写好了,现在运行一下程序,效果如图所示:
两个Fragment平分了整个Activity的布局。
2.2 动态添加Fragment上一节中在布局文件中添加Fragment的方法,不过Fragment真正的强大之处在于,它可以在程序运行时动态地添加到Activity当中。根据具体情况来动态地添加Fragment,就可以将程序界面定制得更加多样化。
在上一节代码的基础上继续完善,新建another_right_fragment.xml,代码如下所示:
这个布局文件的代码和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,代码如下所示:
可以看到,现在将右侧Fragment替换成了一个frameLayout。由于这里仅需要在布局里放入一个Fragment,不需要任何定位,因此非常适合使用frameLayout。
下面在代码中向frameLayout里添加内容,从而实现动态添加Fragment的功能。修改MainActivity中的代码,如下所示:
class MainActivity : baseActivity() {
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 fragmentManger = supportFragmentManager
val transaction = fragmentManger.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之后,这时按下Back键程序就会直接退出。如果想实现类似于返回栈的效果,按下Back键可以回到上一个Fragment,该如何实现呢?
其实很简单,FragmentTransaction中提供了一个addToBackStack()方法,可以用于将一个事务添加到返回栈中。修改MainActivity中的代码,如下所示:
class MainActivity : baseActivity() {
...
private fun replaceFragment(fragment: Fragment) {
val fragmentManger = supportFragmentManager
val transaction = fragmentManger.beginTransaction()
transaction.replace(R.id.rightLayout, fragment)
transaction.addToBackStack(null)
transaction.commit()
}
}
在事务提交之前调用了FragmentTransaction.addToBackStack()方法,它可以接收一个名字用于描述返回栈的状态,一般传入null即可。重新运行程序,并点击按钮将AnotherRightFragment添加到Activity中,然后按下Back键,会发现程序并没有退出,而是回到了RightFragment界面。继续按下Back键,RightFragment界面也会消失,再次按下Back键,程序才会退出。
2.4 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
那么在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之间可不可以进行通信呢?它的基本思路非常简单:首先在一个Fragment中可以得到与它相关联的Activity,然后再通过这个Activity去获取另外一个Fragment的实例,这样就实现了不同Fragment之间的通信功能。
3 Fragment的生命周期 3.1 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的生命周期,通过以下的例子来实践一下。修改RightFragment中的代码,如下所示:
class RightFragment : Fragment() {
companion object {
const val TAG = "RightFragment"
}
override fun onAttach(context: Context) {
super.onAttach(context)
Log.e(TAG, "onAttach")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.e(TAG, "onCreate")
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
Log.e(TAG, "onCreateView")
return inflater.inflate(R.layout.right_fragment, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
Log.e(TAG, "onActivityCreated")
}
override fun onStart() {
super.onStart()
Log.e(TAG, "onStart")
}
override fun onResume() {
super.onResume()
Log.e(TAG, "onResume")
}
override fun onPause() {
super.onPause()
Log.e(TAG, "onPause")
}
override fun onStop() {
super.onStop()
Log.e(TAG, "onStop")
}
override fun onDestroyView() {
super.onDestroyView()
Log.e(TAG, "onDestroyView")
}
override fun onDestroy() {
super.onDestroy()
Log.e(TAG, "onDestroy")
}
override fun onDetach() {
super.onDetach()
Log.e(TAG, "onDetach")
}
}
在Kotlin中定义常量都是使用的这种方式,在companion object、单例类或顶层作用域中使用const关键字声明一个变量即可。
接下来,在RightFragment中的每一个回调方法里都加入了打印日志的代码,然后重新运行程序,观察Logcat中的打印信息:
// RightFragment: onAttach // RightFragment: onCreate // RightFragment: onCreateView // RightFragment: onActivityCreated // RightFragment: onStart // RightFragment: onResume
可以看到,**当RightFragment第一次被加载到屏幕上时,会依次执行onAttach()、onCreate()、onCreateView()、onActivityCreated()、onStart()和onResume()方法。**然后点击LeftFragment中的按钮,此时打印信息如下所示:
// RightFragment: onPause // RightFragment: onStop // RightFragment: onDestroyView
由于AnotherRightFragment替换了RightFragment,此时的RightFragment进入了停止状态,因此onPause()、onStop()和onDestroyView()方法会得到执行。当然,如果在替换的时候没有调用addToBackStack()方法,此时的RightFragment就会进入销毁状态, onDestroy()和onDetach()方法就会得到执行。
接着按下Back键,RightFragment会重新回到屏幕,打印信息如下所示:
// RightFragment: onCreateView // RightFragment: onActivityCreated // RightFragment: onStart // RightFragment: onResume
由于RightFragment重新回到了运行状态,因此onCreateView()、onActivityCreated()、onStart()和onResume()方法会得到执行。注意,此时onCreate()方法并不会执行,因为借助了addToBackStack()方法使得RightFragment并没有被销毁。
现在再次按下Back键,打印信息如图所示:
// RightFragment: onPause // RightFragment: onStop // RightFragment: onDestroyView // RightFragment: onDestroy // RightFragment: onDetach
依次执行onPause()、onStop()、onDestroyView()、onDestroy()和onDetach()方法,最终将Fragment销毁。
另外,在Fragment中也可以通过onSaveInstanceState()方法来保存数据,因为进入停止状态的Fragment有可能在系统内存不足的时候被回收。保存下来的数据在onCreate()、onCreateView()和onActivityCreated()这3个方法中都可以重新得到,它们都含有一个Bundle类型的savedInstanceState参数。
4 动态加载布局的技巧 4.1 使用限定符很多平板应用采用的是双页模式(程序会在左侧的面板上显示一个包含子项的列表,在右侧的面板上显示内容),因为平板的屏幕足够大,完全可以同时显示两页的内容,但手机的屏幕就只能显示一页的内容,因此两个页面需要分开显示。
那么怎样才能在运行时判断程序应该是使用双页模式还是单页模式呢?这就需要借助限定符(qualifier)来实现了。 下面通过一个例子来学习一下它的用法,修改activity_main.xml文件,代码如下所示:
这里只留下一个左侧Fragment,并让它充满整个父布局。接着在res目录 下新建layout-large文件夹,在这个文件夹下新建一个布局,也叫作activity_main.xml,代码如下所示:
可以看到,layout/activity_main布局只包含了一个Fragment,即单页模式,而layout- large/ activity_main布局包含了两个Fragment,即双页模式。其中,large就是一个限定符,那些屏幕被认为是large的设备就会自动加载layout-large文件夹下的布局,小屏幕的设备则还是会加载layout文件夹下的布局。
然后将MainActivity中replaceFragment()方法里的代码注释掉,并在平板模拟器上重新运行程序,效果如图所示:
再启动一个手机模拟器,并重新运行程序,效果如图所示:
这样就实现了在程序运行时动态加载布局的功能。Android中一些常见的限定符可以如图所示:
4.2 使用最小宽度限定符在上一小节中使用large限定符解决了单页双页的判断问题,不过又有一个新的问题出现了:large到底是指多大呢?有时候我们希望可以更加灵活地为不同设备加载布局,不管它们是不是被系统认定为large,这时就可以使用最小宽度限定符(smallest-width qualifier)。
最小宽度限定符允许我们对屏幕的宽度指定一个最小值(以dp为单位),然后以这个最小值为临界点,屏幕宽度大于这个值的设备就加载一个布局,屏幕宽度小于这个值的设备就加载另一个布局。
在res目录下新建layout-sw600dp文件夹,然后在这个文件夹下新建activity_main.xml布 局,代码如下所示:
这就意味着,当程序运行在屏幕宽度大于等于600dp的设备上时,会加载layout- sw600dp/activity_main布局,当程序运行在屏幕宽度小于600dp的设备上时,则仍然加载默认的layout/activity_main布局。
5 Fragment的最佳实践:一个简易版的新闻应用Fragment很多时候是在平板开发当中使用的,因为它可以解决屏幕空间不能充分利用的问题。那是不是就表明,我们开发的程序都需要提供一个手机版和一个平板版呢?确实有不少公司是这么做的,但是这样会耗费很多的人力物力财力。因为维护两个版本的代码成本很高:每当增加新功能时,需要在两份代码里各写一遍;每当发现一个bug时,需要在两份代码里各修改一次。因此,今天我们最佳实践的内容就是教你如何编写兼容手机和平板的应用程序。
编写一个简易版的新闻应用,并且要求它可以兼容手机和平板。
要准备好一个新闻的实体类,新建类News,代码如下所示:
class News(val title: String, val content: String)
News类的代码非常简单,title字段表示新闻标题,content字段表示新闻内容。接着新建布局文件news_content_frag.xml,作为新闻内容的布局:
新闻内容的布局主要可以分为两个部分:头部部分显示新闻标题,正文部分显示新闻内容,中间使用一条水平方向的细线分隔开。除此之外,这里还使用了一条垂直方向的细线,它的作用 是在双页模式时将左侧的新闻列表和右侧的新闻内容分隔开。细线是利用View来实现的,将View的宽或高设置为1dp,再通过background属性给细线设置一下颜色就可以了,这里把细线设置成黑色。
另外,还要将新闻内容的布局设置成不可见。因为在双页模式下,如果还没有选中新闻列表中的任何一条新闻,是不应该显示新闻内容布局的。
接下来新建一个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中的代码,如下所示:
这里充分发挥了代码的复用性,直接在布局中引入了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("news_title", title)
putExtra("news_content", content)
}
context.startActivity(intent)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_news_content)
val title = intent.getStringExtra("news_title")
val content = intent.getStringExtra("news_content")
if (title != null && content != null) {
val fragment = newsContentFrag as NewsContentFragment
fragment.refresh(title, content)
}
}
}
可以看到,在onCreate()方法中我们通过Intent获取到了传入的新闻标题和新闻内容,然后使用kotlin-android-extensions插件提供的简洁写法得到了NewsContentFragment的实例,接着调用它的refresh()方法,将新闻的标题和内容传入,就可以把这些数据显示出来了。注意,这里还提供了一个actionStart()方法。
接下来还需要再创建一个用于显示新闻列表的布局,新建news_title_frag.xml,代码如下所示:
这个布局的代码就非常简单了,里面只有一个用于显示新闻列表的RecyclerView。新建news_item.xml作为RecyclerView子项的布局,代码如下所示:
子项的布局也非常简单,只有一个TextView。对于TextView的属性,android:maxLines设置为1表示让这个TextView只能单行显示;android:ellipsize用于设定当文本内容超出控件宽度时文本的缩略方式,这里指定成end表示在尾部进行缩略。
接下来就需要一个用于展示新闻列表的地方。新建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(R.id.newsContentLayout)!=null
}
}
在NewsTitleFragment的onActivityCreated()方法,这个方法通过在Activity中能否找到一个id为newsContentLayout的View,来判断当前是双页模式还是单页模式,因此需要让这个id为newsContentLayout的View只在双页模式中才会出现。注意,由于在Fragment中调用getActivity()方法有可能返回null,所以在上述代码中使用了一个?.操作符来保证代码的安全性。
那么怎样才能实现让id为newsContentLayout的View只在双页模式中才会出现呢?其实并不复杂,只需要借助限定符就可以了。首先修改activity_main.xml中的代码,如下所示:
上述代码表示在单页模式下只会加载一个新闻标题的Fragment。
然后新建layout-sw600dp文件夹,在这个文件夹下再新建一个activity_main.xml文件,代码如下所示:
在双页模式下,同时引入了两个Fragment,并将新闻内容的Fragment放在了 一个frameLayout布局下,而这个布局的id正是newsContentLayout。因此,能够找到这个id的时候就是双页模式,否则就是单页模式。
还剩下一点,就是在NewsTitleFragment中通过RecyclerView将新闻列表展示出来。在NewsTitleFragment中新建一个内部类NewsAdapter来作为RecyclerView的适配器,如下所示:
class NewsTitleFragment : Fragment() {
private var isTwoPane = false
...
inner class NewsAdapter(val newList: List) :
RecyclerView.Adapter() {
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val newsTitle: TextView = view.findViewById(R.id.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 {
val news = newList[holder.adapterPosition]
if (isTwoPane) {
val fragment = newsContentFrag as NewsContentFragment
fragment.refresh(news.title, news.content)
} else {
MainActivity.actionStart(parent.context, news.title, news.content)
}
}
return holder
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val news = newList[position]
holder.newsTitle.text = news.title
}
override fun getItemCount() = newList.size
}
}
要注意的是,适配器写可以写成内部类。这里 写成内部类的好处就是可以直接访问NewsTitleFragment的变量,比如isTwoPane。
onCreateViewHolder()方法中注册的点击事件,首先获取了点击项的News实例,然后通过isTwoPane变量判断当前是单页还是双页模式。如果是单页模式,就启动一个新的Activity去显示新闻内容:如果是双页模式,就更新NewsContentFragment里的数据。
接下来就是向RecyclerView中填充数据了。修改NewsTitleFragment中的代码,如下所示:
class NewsTitleFragment : Fragment() {
...
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
isTwoPane = activity?.findViewById(R.id.newsContentLayout) != null
val layoutManager = LinearLayoutManager(activity)
newsTitleRecyclerView.layoutManager = layoutManager
val adapter = NewsAdapter(getNews())
newsTitleRecyclerView.adapter = adapter
}
private fun getNews(): List {
val newsList = ArrayList()
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()
}
...
}
可以看到,在Fragment中使用RecyclerView和在Activity中使用几乎是一模一样的。另外,这里调用了getNews()方法来初始化50条模拟新闻数据,同样使用了一个getRandomLengthString()方法来随机生成新闻内容的长度,以保证每条新闻的内容差距比较大。
这样我们所有的编码工作就已经完成了,首先在手机模拟器上运行,效果如图所示:
可以看到许多条新闻的标题,然后点击第一条新闻,会启动一个新的Activity来显示新闻的内容,效果如图所示:
接下来将程序在平板模拟器上运行,同样点击第一条新闻,效果如图所示:
同样的一份代码,在手机和平板上运行却得到两种完全不同的效果,这说明程序的兼容性已经相当不错了。



