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 来显示新闻内容。

在平板上的效果:

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

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

相关推荐
千里马学框架1 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台1 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone1 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc1 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo1 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077001 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼1 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone1 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen1 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone1 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui