Fragment 最佳实践:兼容手机和平板的简易新闻应用

前言

Fragment 能让我们充分利用平板的屏幕空间。但如果一个应用要为手机、平板都开发一个版本,就有些得不偿失了,开发和维护的成本很高,也增加了代码管理的复杂度。

所以,现在我们来编写一个能够兼容手机和平板的新闻应用,用一份代码应对不同屏幕尺寸的设备。

准备工作

首先,新建一个名为 FragmentBestPracticeEmpty Views Activity 项目。

然后,在 app/build.gradle.kts 配置文件中:添加 RecyclerView 的依赖,因为我们需要使用 RecyclerView 列表控件来展示可滚动的新闻列表;启用视图绑定,它可以让我们方便、安全地获取布局中的控件实例。

如下所示:

kotlin 复制代码
android {
    ...

    buildFeatures {
        viewBinding = true
    }

    ...
}

dependencies {
    implementation("androidx.recyclerview:recyclerview:1.4.0")
    ...
}

新建承载新闻数据的数据类 News,代码如下:

kotlin 复制代码
data class News(val title: String, val content: String)

其中 title 字段表示新闻标题,content 字段表示新闻内容。

创建新闻内容页

首先我们来创建展示新闻内容的界面,这个界面在平板上会在右侧显示,在手机上会使用单独的页面来显示。

内容页布局

创建布局文件 news_content_frag.xml,代码如下:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    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:gravity="center"
        android:padding="15dp"
        android:textSize="18sp" />


</LinearLayout>

该布局中有着新闻标题和新闻内容,它们之间使用一条水平黑色细线进行分隔。细线的实现也很简单,只是使用了一个 View 组件,将其粗细(宽度或高度)设为 1dp,颜色再设为黑色即可。

另外,新闻内容页默认是不可见的 ,我们将根布局 LinearLayoutandroid:visibility 属性设置为 invisible。这是因为在双页模式下,如果用户还没有点击任何新闻,右侧区域是不应该显示新闻内容布局的,应该是空白。

内容页 Fragment

然后创建新闻内容页对应的 Fragment 类:NewsContentFragment,继承自 Fragment,代码如下:

kotlin 复制代码
class NewsContentFragment : Fragment() {
    private var _binding: NewsContentFragBinding? = null
    private val binding get() = _binding!!

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

    /**
     * 将隐藏的新闻内容布局设为可见,并且刷新界面中的新闻标题和新闻内容
     */
    fun refresh(title: String, content: String) {
        // 显示新闻内容页布局
        binding.contentLayout.visibility = View.VISIBLE
        binding.newsTitle.text = title // 刷新新闻的标题
        binding.newsContent.text = content // 刷新新闻的内容
    }
    
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

我们在这个 Fragment 中,创建了一个 refresh 方法,用来显示布局并且更新数据。

适配单页模式

在手机的这种单页模式下,当点击了新闻标题后,应该要创建一个新的 Activity 来承载 NewsContentFragment

创建容器 Activity

创建一个 Activity,命名为 NewsContentActivity,对应的布局文件为 activity_news_content。在该布局文件中,我们静态加载之前完成的 NewsContentFragment,代码如下:

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.fragment.app.FragmentContainerView
        android:id="@+id/newsContentFrag"
        android:name="com.example.fragmentbestpractice.NewsContentFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</LinearLayout>

这样,单页模式就有了新闻内容页。

传递数据

然后在 NewsContentActivity 中,添加启动当前 Activity 的方法,并且将传入的数据设置到新闻内容页 Fragment 中。代码如下:

kotlin 复制代码
class NewsContentActivity : AppCompatActivity() {

    companion object {
        /**
         * 启动当前 Activity 的快捷方法
         */
        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)
        }
    }

    private lateinit var binding: ActivityNewsContentBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityNewsContentBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 获取通过 Intent 传递的新闻数据
        val title = intent.getStringExtra("news_title")
        val content = intent.getStringExtra("news_content")

        // 只有在 Activity 第一次创建时才需要设置 arguments
        if (savedInstanceState == null) {
            val fragment = binding.newsContentFrag.getFragment<NewsContentFragment>()
            fragment.arguments = Bundle().apply {
                putString("news_title", title)
                putString("news_content", content)
            }
        }
    }
}

我们在 onCreate() 方法中通过 Intent 对象获取了传入的新闻数据,然后获取到界面中的 FragmentContainerView 实例,并且调用其 getFragment() 方法获取到了 NewsContentFragment 对象,并且通过其 setArguments() 方法向 NewsContentFragment 传递数据。

为什么我们这里没有直接调用 NewsContentFragment.refresh 方法,而是使用了 arguments 来传递数据?

这是因为 Activity 与其内部的 Fragment 存在着生命周期的差异:在 Activity 的 onCreate 方法执行时,Fragment 的视图可能还没被创建好。如果此时直接调用 refresh 方法去操作视图组件,会抛出 java.lang.NullPointerException 异常,其实也就是 Fragment 的视图绑定对象 binding 为空。

使用 arguments 是官方推荐的做法,而且还安全。它能保证在 Fragment 的任何生命周期阶段都能被安全地访问。

最后 if (savedInstanceState == null) 条件判断,是防止 Activity 重新创建时,可以不用重复设置 arguments

最后,修改 NewsContentFragment,需要在 onViewCreated 方法中,从 arguments 中拿到数据并更新界面。

kotlin 复制代码
class NewsContentFragment : Fragment() {
    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // 此时 binding 不为空
        // 从 arguments 中获取数据并更新界面
        val title = arguments?.getString("news_title")
        val content = arguments?.getString("news_content")

        if (title != null && content != null) {
            refresh(title, content)
        }
    }

    ...
}

创建新闻标题列表

现在,我们来创建左侧的新闻标题列表。

列表与列表项布局

首先创建列表布局,新建 news_title_frag.xml 布局文件,代码如下:

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>

创建列表项布局,新建 news_item.xml 布局文件,代码如下:

xml 复制代码
<?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。

解释一下 ellipsize 属性,它可指定当文本内容超出控件的宽度后,文本的缩略方式。我们设置为 end,表示尾部进行缩略,比如 "这是一条很长的新闻标..."。

标题列表 Fragment 与 Adapter

创建一个 NewsTitleFragment,代码如下:

kotlin 复制代码
class NewsTitleFragment : Fragment() {
    private var _binding: NewsTitleFragBinding? = null
    private val binding get() = _binding!!

    // 是否为双页模式
    private var isTwoPane = false

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

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        isTwoPane = requireActivity().findViewById<View>(R.id.newsContentLayout) != null
    }

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

}

onViewCreated 方法中,我们会通过是否能在 Activity 中找到一个 id 为 newsContentLayout 的 View,来判断当前是双页还是单页模式。

然后就是创建 RecyclerView 的适配器,直接在 NewsTitleFragment 中新建一个内部类 NewsAdapter 作为适配器,代码如下:

kotlin 复制代码
class NewsTitleFragment : Fragment() {
    ...

    // 是否为双页模式
    private var isTwoPane = false

    ...

    inner class NewsAdapter(private val newsList: List<News>) :
        RecyclerView.Adapter<NewsAdapter.ViewHolder>() {


        inner class ViewHolder(private val newsItemBinding: NewsItemBinding) :
            RecyclerView.ViewHolder(newsItemBinding.root) {
            fun bind(news: News) {
                newsItemBinding.newsTitle.text = news.title
            }
        }

        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {

            val newsItemBinding = NewsItemBinding.inflate(layoutInflater, parent, false)

            val holder = ViewHolder(newsItemBinding)

            newsItemBinding.newsTitle.setOnClickListener {
                val position = holder.bindingAdapterPosition // 获取 ViewHolder 在适配器中的位置
                if (position != RecyclerView.NO_POSITION) {
                    val news = newsList[position] // 获取当前新闻标题对应的新闻数据
                    if (isTwoPane) {
                        // 如果是双页模式,则刷新 NewsContentFragment 中的内容
                        val fragment =
                            parentFragmentManager.findFragmentById(R.id.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.bind(news)
        }

        override fun getItemCount() = newsList.size
    }
}

我们给新闻标题注册了点击事件,在点击回调中,首先获取了新闻标题对应的新闻数据,也就是 News 实例,然后通过 isTwoPane 属性判断当前是单页还是双页模式。如果是单页模式,就启动一个 NewsContentActivity 显示新闻内容;如果是双页模式,就更新当前 MainActivity 对应布局中 NewsContentFragment 里的数据。

使用限定符实现动态布局

那怎么让标识符为 newsContentLayout 的控件只有在双页模式中才存在呢?

很简单,使用最小宽度限定符。

默认布局

单页模式下,我们只加载一个新闻标题列表。在 res/layout/activity_main.xml 布局文件中:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/newsTitleFrag"
        android:name="com.example.fragmentbestpractice.NewsTitleFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</FrameLayout>

平板布局

而在双页模式下,我们同时加载新闻标题列表和新闻内容页。新建 res/layout-sw600dp 文件夹,在该文件夹下创建 activity_main.xml 布局文件。这样当设备的最小宽度大于等于 600dp 时,系统会自动加载这个布局。代码如下:

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">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/newsTitleFrag"
        android:name="com.example.fragmentbestpractice.NewsTitleFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />
    
     <View
        android:layout_width="1dp"
        android:layout_height="match_parent"
        android:layout_alignParentStart="true"
        android:background="#000" />

    <FrameLayout
        android:id="@+id/newsContentLayout"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="3">

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/newsContentFrag"
            android:name="com.example.fragmentbestpractice.NewsContentFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </FrameLayout>
</LinearLayout>

在平板布局中,我们才有标识符为 newsContentLayoutFrameLayout。所以只要能在 Activity 的布局中找到这个标识符,说明当前是双页模式。

删除多余代码

MainActivity 中,因为对应的布局中已经不存在 id 为 main 的视图了,所以我们需要删除多余的代码,修改后:

kotlin 复制代码
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

填充数据

最后就是往新闻列表中填充数据了,回到 NewsTitleFragment 中:

kotlin 复制代码
class NewsTitleFragment : Fragment() {
    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        isTwoPane = requireActivity().findViewById<View>(R.id.newsContentLayout) != null

        // 设置布局管理器
        val layoutManager = LinearLayoutManager(activity)
        binding.newsTitleRecyclerView.layoutManager = layoutManager
        val adapter = NewsAdapter(getNews())
        // 设置适配器
        binding.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()
    }

    ...
}

结果

这样全部就完成了,开始运行!

手机上的效果:

你会看到一个新闻标题列表,点击任意一条新闻,会启动新的 NewsContentActivity 来显示新闻内容。

在平板上的效果:

你会看见左右分栏的布局,左侧是新闻标题列表,右侧默认是空白。随意点击一条新闻,右侧会显示新闻内容,效果如图:

同一份代码,在手机和平板上运行却有不同的效果,这说明当前程序的兼容性还是不错的。

相关推荐
xzkyd outpaper1 小时前
onSaveInstanceState() 和 ViewModel 在数据保存能力差异
android·计算机八股
CYRUS STUDIO2 小时前
FART 脱壳某大厂 App + CodeItem 修复 dex + 反编译还原源码
android·安全·逆向·app加固·fart·脱壳
WAsbry2 小时前
现代 Android 开发自定义主题实战指南
android·kotlin·material design
xzkyd outpaper3 小时前
Android动态广播注册收发原理
android·计算机八股
唐墨1233 小时前
android与Qt类比
android·开发语言·qt
林林要一直努力4 小时前
Android Studio 向模拟器手机添加照片、视频、音乐
android·智能手机·android studio
AD钙奶-lalala4 小时前
Mac版本Android Studio配置LeetCode插件
android·ide·android studio
散人10245 小时前
Android Test3 获取的ANDROID_ID值不同
android·unit testing
雨白5 小时前
实现动态加载布局
android
帅得不敢出门6 小时前
Android设备推送traceroute命令进行网络诊断
android·网络