碎片

平板电脑和手机最大的区别就在于屏幕的大小, 一般手机屏幕的大小会在 3 英寸到 6 英寸之间, 而一般平板电脑屏幕的大小会在 7 英寸到 10 英寸之间。屏幕大小差距过大有可能会让同样的界面在视觉效果上有较大的差异, 比如一些界面在手机上看起来非常美观, 但在平板电脑上看起来就可能会有控件被过分拉长、元素之间空隙过大等情况。

作为一名专业的 Android 开发人员, 能够同时兼顾手机和平板的开发是我们必须做到的事情。Android 自 3.0 版本开始引入了碎片的概念, 它可以让界面在平板上更好地展示。

碎片是什么

碎片(Fragment)是一种可以嵌入在活动当中的 UI 片段,它能让程序更加合理和充分地利用大屏幕的空间,因而在平板上应用得非常广泛。

它和活动实在是太像了,同样都能包含布局,同样都有自己的生命周期。你甚至可以将碎片理解成一个迷你型的活动,虽然这个迷你型的活动有可能和普通的活动是一样大的。

那么究竟要如何使用碎片才能充分地利用平板屏幕的空间呢?想象我们正在开发一个新闻应用,其中一个界面使用 RecyclerView 展示了一组新闻的标题,当点击了其中一个标题时,就打开另一个界面显示新闻的详细内容。如果是在手机中设计,我们可以将新闻标题列表放在一个活动中,将新闻的详细内容放在另一个活动中,如图所示:

可是如果在平板上也这么设计,那么新闻标题列表将会被拉长至填充满整个平板的屏幕,而新闻的标题一般都不会太长,这样将会导致界面上有大量的空白区域,如图:

因此,更好的设计方案是将新闻标题列表界面和新闻详细内容界面分别放在两个碎片中,然后在同一个活动里引入这两个碎片,这样就可以将屏幕空间充分地利用起来了,如图:

碎片的使用方式

碎片通常都是在平板开发中使用的,新建一个 FragmentTest 项目,创建一个平板模拟器,开始我们的碎片探索之旅吧。效果如图:

碎片的简单用法

先写一个最简单的碎片示例来练练手,在一个活动当中添加两个碎片,并让这两个碎片平分活动空间。

新建一个左侧碎片布局 left_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: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>

这个布局非常简单, 只放置了一个按钮,并让它水平居中显示。

然后新建右侧碎片布局 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="#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="20sp" />
</LinearLayout>

接着新建一个 LeftFragment 类,并让它继承自 Fragment。

书中说的这一坨:“注意,这里可能会有两个不同包下的 Fragment 供你选择,一个是系统内置的 android.app.Fragment,一个是 support-v4 库中的 android.support.v4.app.Fragment。这里我强烈建议你使用 support-v4 库中的 Fragment,因为它可以让碎片在所有 Android 系统版本中保持功能一致性。比如说在 Fragment 中嵌套使用 Fragment,这个功能是在 Android 4.2 系统中才开始支持的,如果你使用的是系统内置的 Fragment,那么很遗憾,4.2 系统之前的设备运行你的程序就会崩溃。而使用 support-v4 库中的 Fragment 就不会出现这个问题,只要你保证使用的是最新的 support-v4 库就可以了。另外,我们并不需要在 build.gradle 文件中添加 support-v4 库的依赖,因为 build.gradle 文件中已经添加了 appcompat-v7 库的依赖,而这个库会将 support-v4 库也一起引入进来。”

已经过时了,只需要在书写 LeftFragment 类时,进行导包:import androidx.fragment.app.Fragment; 即可。

LeftFragment 类的代码:

package com.example.fragmenttest;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
public class LeftFragment extends Fragment {
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.left_fragment, container, false);
return view;
}
}

这里仅仅是重写了 Fragment 的 onCreateView() 方法,然后在这个方法中通过 LayoutInflater 的 inflate() 方法将刚才定义的 left_fragment 布局动态加载进来,整个方法简单明了。

接着我们用同样的方法再新建一个 RightFragment,代码如下所示:

package com.example.fragmenttest;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
public class RightFragment extends Fragment {
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.right_fragment, container, false);
return view;
}
}

基本上代码都是相同的, 相信已经没有必要再做什么解释了。

接下来修改 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/left_fragment"
android:name="com.example.fragmenttest.LeftFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<fragment
android:id="@+id/right_fragment"
android:name="com.example.fragmenttest.RightFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
</LinearLayout>

可以看到, 我们使用了 <fragment> 标签在布局中添加碎片,其中还需要通过 android:name 属性来显式指明要添加的碎片类名,注意一定要将类的包名也加上。

这样最简单的碎片示例就已经写好了,现在运行一下程序,效果如图:

正如我们所期待的一样, 两个碎片平分了整个活动的布局。不过这个例子实在是太简单了,在真正的项目中很难有什么实际的作用。

动态添加碎片

在上一节当中,你已经学会了在布局文件中添加碎片的方法,不过碎片真正的强大之处在于,它可以在程序运行时动态地添加到活动当中。

根据具体情况来动态地添加碎片,你就可以将程序界面定制得更加多样化。

还是在上一节代码的基础上继续完善,新建 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="Another right fragment"
android:textSize="20sp" />
</LinearLayout>

这个布局文件的代码和 right_fragment.xml 中的代码基本相同,只是将背景色改成了黄色,并将显示的文字改了改。然后新建 AnotherRightFragment 作为另一个右侧碎片,代码如下所示:

package com.example.fragmenttest;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
public class AnotherRightFragment extends Fragment {
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.another_right_fragment, container, false);
return view;
}
}

代码同样非常简单,在 onCreateView() 方法中加载了刚刚创建的 another_right_fragment 布局。这样我们就准备好了另一个碎片,接下来看一下如何将它动态地添加到活动当中。修改 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/left_fragment"
android:name="com.example.fragmenttest.LeftFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
android:id="@+id/right_layout"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
</LinearLayout>

可以看到,现在将右侧碎片替换成了一个 FrameLayout,在上一章中学过,这是 Android 中最简单的一种布局,所有的控件默认都会摆放在布局的左上角。由于这里仅需要在布局里放入一个碎片,不需要任何定位,因此非常适合使用 FrameLayout。

下面在代码中向 FrameLayout 里添加内容,从而实现动态添加碎片的功能。修改 MainActivity 中的代码,如下所示:

package com.example.fragmenttest;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button button = (Button) findViewById(R.id.button);
button.setOnClickListener(this);
replaceFragment(new RightFragment());
}
@Override
public void onClick(View view) {
switch (view.getId()) {
case R.id.button:
replaceFragment(new AnotherRightFragment());
break;
default:
break;
}
}
private void replaceFragment(Fragment fragment) {
FragmentManager fragmentManager = getSupportFragmentManager();
FragmentTransaction transaction = fragmentManager.beginTransaction();
transaction.replace(R.id.right_layout, fragment);
transaction.commit();
}
}

首先给左侧碎片中的按钮注册了一个点击事件,然后调用 replaceFragment() 方法动态添加了 RightFragment 这个碎片。当点击左侧碎片中的按钮时,又会调用 replaceFragment() 方法将右侧碎片替换成 AnotherRightFragment。

结合 replaceFragment() 方法中的代码可以看出,动态添加碎片主要分为 5 步。

  1. 创建待添加的碎片实例。

  2. 获取 FragmentManager,在活动中可以直接通过调用 getSupportFragmentManager() 方法得到。

  3. 开启一个事务,通过调用 beginTransaction() 方法开启。

  4. 向容器内添加或替换碎片,一般使用 replace() 方法实现,需要传入容器的 id 和待添加的碎片实例。

  5. 提交事务,调用 commit() 方法来完成。

这样就完成了在活动中动态添加碎片的功能,重新运行程序,可以看到和之前相同的界面,然后点击一下按钮,效果如图:

在碎片中模拟返回栈

成功实现了向活动中动态添加碎片的功能,不过尝试一下就会发现,通过点击按钮添加了一个碎片之后,这时按下Back键程序就会直接退出。

如果这里想模仿类似于返回栈的效果,按下Back键可以回到上一个碎片,该如何实现呢?

其实很简单,FragmentTransaction 中提供了一个 addToBackStack() 方法,可以用于将一个事务添加到返回栈中,修改 MainActivity 中的代码,如下所示:

package com.example.fragmenttest;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button button = (Button) findViewById(R.id.button);
button.setOnClickListener(this);
replaceFragment(new RightFragment());
}
@Override
public void onClick(View view) {
switch (view.getId()) {
case R.id.button:
replaceFragment(new AnotherRightFragment());
break;
default:
break;
}
}
private void replaceFragment(Fragment fragment) {
FragmentManager fragmentManager = getSupportFragmentManager();
FragmentTransaction transaction = fragmentManager.beginTransaction();
transaction.replace(R.id.right_layout, fragment);
// 在事务提交之前调用了 FragmentTransaction 的 addToBackStack() 方法,
// 它可以接收一个名字用于描述返回栈的状态,一般传入 null 即可。
transaction.addToBackStack(null);
transaction.commit();
}
}

这里我们在事务提交之前调用了 FragmentTransaction 的 addToBackStack() 方法,它可以接收一个名字用于描述返回栈的状态,一般传入 null 即可。现在重新运行程序,并点击按钮将 AnotherRightFragment 添加到活动中,然后按下 Back 键,会发现程序并没有退出,而是回到了 RightFragment 界面,继续按下 Back 键,RightFragment 界面也会消失,再次按下 Back 键,程序才会退出。

如果按下多次左侧的按钮,就需要按下同样次数的 Back 才能让右侧从黄色变回绿色。

这里,当右侧变为黄色后,又按了三下左侧的按钮,所以返回时,要多按三次 Back。

碎片和活动之间进行通信

虽然碎片都是嵌入在活动中显示的,可是实际上它们的关系并没有那么亲密。

碎片和活动都是各自存在于一个独立的类当中的,它们之间并没有那么明显的方式来直接进行通信。如果想要在活动中调用碎片里的方法,或者在碎片中调用活动里的方法,应该如何实现呢?

为了方便碎片和活动之间进行通信,FragmentManager 提供了一个类似于 findViewById() 的方法,专门用于从布局文件中获取碎片的实例,代码如下所示:

RightFragment rightFragment = (RightFragment) getSupportFragmentManager().findFragmentById(R.id.right_fragment);

调用 FragmentManager 的 findFragmentById() 方法,可以在活动中得到相应碎片的实例,然后就能轻松地调用碎片里的方法了。

掌握了如何在活动中调用碎片里的方法,那在碎片中又该怎样调用活动里的方法呢?其实这就更简单了,在每个碎片中都可以通过调用 getActivity() 方法来得到和当前碎片相关联的活动实例,代码如下所示:

MainActivity activity = (MainActivity) getActivity();

有了活动实例之后,在碎片中调用活动里的方法就变得轻而易举了。另外当碎片中需要使用 Context 对象时,也可以使用 getActivity() 方法,因为获取到的活动本身就是一个 Context 对象。

这时不知道你心中会不会产生一个疑问:既然碎片和活动之间的通信问题已经解决了,那么碎片和碎片之间可不可以进行通信呢?

这个问题并没有看上去那么复杂,它的基本思路非常简单,首先在一个碎片中可以得到与它相关联的活动,然后再通过这个活动去获取另外一个碎片的实例,这样也就实现了不同碎片之间的通信功能,因此这里我们的答案是肯定的。

碎片的生命周期

和活动一样,碎片也有自己的生命周期,并且它和活动的生命周期实在是太像了。

碎片的状态和回调

还记得每个活动在其生命周期内可能会有哪几种状态吗? 没错, 一共有运行状态、暂停状态、停止状态和销毁状态这 4 种。类似地, 每个碎片在其生命周期内也可能会经历这几种状态, 只不过在一些细小的地方会有部分区别。

1、运行状态

当一个碎片是可见的,并且它所关联的活动正处于运行状态时,该碎片也处于运行状态。

2、暂停状态

当一个活动进入暂停状态时(由于另一个未占满屏幕的活动被添加到了栈顶),与它相关联的可见碎片就会进入到暂停状态。

3、停止状态

当一个活动进入停止状态时,与它相关联的碎片就会进入到停止状态,或者通过调用 FragmentTransaction 的 remove()、replace() 方法将碎片从活动中移除,但如果在事务提交之前调用 addToBackStack() 方法,这时的碎片也会进入到停止状态。总的来说,进入停止状态的碎片对用户来说是完全不可见的,有可能会被系统回收。

4、销毁状态

碎片总是依附于活动而存在的,因此当活动被销毁时,与它相关联的碎片就会进入到销毁状态。或者通过调用 FragmentTransaction 的 remove()、replace() 方法将碎片从活动中移除,但在事务提交之前并没有调用 addToBackStack() 方法,这时的碎片也会进入到销毁状态。

同样地,Fragment 类中也提供了一系列的回调方法,以覆盖碎片生命周期的每个环节。其中,活动中有的回调方法,碎片中几乎都有,不过碎片还提供了一些附加的回调方法,那我们就重点看一下这几个回调。

  • onAttach()。当碎片和活动建立关联的时候调用。

  • onCreateView()。为碎片创建视图(加载布局)时调用。

  • onActivityCreated()。确保与碎片相关联的活动一定已经创建完毕的时候调用。

  • onDestroyView()。当与碎片关联的视图被移除的时候调用。

  • onDetach()。当碎片和活动解除关联的时候调用。

碎片完整的生命周期示意图可参考下图,图片源自 Android 官网。

体验碎片的生命周期

为了能够更加直观地体验碎片的生命周期,还是通过一个例子来实践一下。例子很简单,仍然是在 FragmentTest 项目的基础上改动的。修改 RightFragment 中的代码,如下所示:

package com.example.fragmenttest;
import android.content.Context;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
public class RightFragment extends Fragment {
public static final String TAG = "RightFragment";
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
Log.d(TAG, "onAttach");
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Log.d(TAG, "onCreate");
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
Log.d(TAG, "onCreateView");
View view = inflater.inflate(R.layout.right_fragment, container, false);
return view;
}
@Override
// 被弃用
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
Log.d(TAG, "onActivityCreated");
}
@Override
public void onStart() {
super.onStart();
Log.d(TAG, "onStart");
}
@Override
public void onResume() {
super.onResume();
Log.d(TAG, "onResume");
}
@Override
public void onPause() {
super.onPause();
Log.d(TAG, "onPause");
}
@Override
public void onStop() {
super.onStop();
Log.d(TAG, "onStop");
}
@Override
public void onDestroyView() {
super.onDestroyView();
Log.d(TAG, "onDestroyView");
}
@Override
public void onDestroy() {
super.onDestroy();
Log.d(TAG, "onDestroy");
}
@Override
public void onDetach() {
super.onDetach();
Log.d(TAG, "onDetach");
}
}

我们在 RightFragment 中的每一个回调方法里都加入了打印日志的代码,然后重新运行程序,这时观察 logcat 中的打印信息,如图:

打印的日志:

2025-02-26 13:42:26.441 8991-8991/com.example.fragmenttest D/RightFragment: onAttach
2025-02-26 13:42:26.442 8991-8991/com.example.fragmenttest D/RightFragment: onCreate
2025-02-26 13:42:26.442 8991-8991/com.example.fragmenttest D/RightFragment: onCreateView
2025-02-26 13:42:26.444 8991-8991/com.example.fragmenttest D/RightFragment: onActivityCreated
2025-02-26 13:42:26.445 8991-8991/com.example.fragmenttest D/RightFragment: onStart
2025-02-26 13:42:26.446 8991-8991/com.example.fragmenttest D/RightFragment: onResume

日志过滤时搜索的关机键字:com.example.fragmenttest D/RightFragment: on

可以看到,当 RightFragment 第一次被加载到屏幕上时,会依次执行 onAttach()、onCreate()、onCreateView()、onActivityCreated()、onStart() 和 onResume() 方法。然后点击 LeftFragment 中的按钮,此时打印信息如图:

打印的日志:

2025-02-26 13:44:16.162 8991-8991/com.example.fragmenttest D/RightFragment: onPause
2025-02-26 13:44:16.162 8991-8991/com.example.fragmenttest D/RightFragment: onStop
2025-02-26 13:44:16.166 8991-8991/com.example.fragmenttest D/RightFragment: onDestroyView

由于 AnotherRightFragment 替换了 RightFragment,此时的 RightFragment 进入了停止状态,因此 onPause()、onStop() 和 onDestroyView() 方法会得到执行。

当然如果在替换的时候没有调用 addToBackStack() 方法,此时的 RightFragment 就会进入销毁状态,onDestroy() 和 onDetach() 方法就会得到执行。

接着按下 Back 键,RightFragment 会重新回到屏幕,打印信息如图:

打印的日志:

2025-02-26 13:45:58.923 8991-8991/com.example.fragmenttest D/RightFragment: onCreateView
2025-02-26 13:45:58.926 8991-8991/com.example.fragmenttest D/RightFragment: onActivityCreated
2025-02-26 13:45:58.927 8991-8991/com.example.fragmenttest D/RightFragment: onStart
2025-02-26 13:45:58.927 8991-8991/com.example.fragmenttest D/RightFragment: onResume

由于 RightFragment 重新回到了运行状态,因此 onCreateView()、onActivityCreated()、onStart() 和 onResume() 方法会得到执行。注意此时 onCreate() 方法并不会执行,因为我们借助了 addToBackStack() 方法使得 RightFragment 并没有被销毁。现在再次按下 Back 键,打印信息如图:

打印的日志:

2025-02-26 13:48:26.078 8991-8991/com.example.fragmenttest D/RightFragment: onPause
2025-02-26 13:48:26.078 8991-8991/com.example.fragmenttest D/RightFragment: onStop
2025-02-26 13:48:26.080 8991-8991/com.example.fragmenttest D/RightFragment: onDestroyView
2025-02-26 13:48:26.080 8991-8991/com.example.fragmenttest D/RightFragment: onDestroy
2025-02-26 13:48:26.080 8991-8991/com.example.fragmenttest D/RightFragment: onDetach

依次会执行 onPause()、onStop()、onDestroyView()、onDestroy() 和 onDetach() 方法,最终将碎片销毁掉。这样碎片完整的生命周期体验了一遍。另外值得一提的是,在碎片中你也是可以通过 onSaveInstanceState() 方法来保存数据的,因为进入停止状态的碎片有可能在系统内存不足的时候被回收。保存下来的数据在 onCreate()、onCreateView() 和 onActivityCreated() 这 3 个方法中你都可以重新得到,它们都含有一个 Bundle 类型的 savedInstanceState 参数。

动态加载布局的技巧

虽然动态添加碎片的功能很强大, 可以解决很多实际开发中的问题, 但是它毕竟只是在一个布局文件中进行一些添加和替换操作。如果程序能够根据设备的的分辨率或屏幕大小在运行时来决定加载哪个布局, 那我们可发挥的空间就更多了。

因此本节我们就来探讨一下 Android 中动态加载布局的技巧。

使用限定符

现在很多的平板应用都采用的是双页模式(程序会在左侧的面板上显示一个包含子项的列表,在右侧的面板上显示内容),因为平板电脑的屏幕足够大,完全可以同时显示下两页的内容,但手机的屏幕一次就只能显示一页的内容,因此两个页面需要分开显示。

那么怎样才能在运行时判断程序应该是使用双页模式还是单页模式呢?这就需要借助限定符(Qualifiers)来实现了。

下面我们通过一个例子来学习一下它的用法,修改 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/left_fragment"
android:name="com.example.fragmenttest.LeftFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

将多余的代码都删掉,只留下一个左侧碎片,并让它充满整个父布局。

接着在 res 目录下新建 layout-large 文件夹,在这个文件夹下新建一个布局,也叫作 activity_main.xml

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/left_fragment"
android:name="com.example.fragmenttest.LeftFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<fragment
android:id="@+id/right_fragment"
android:name="com.example.fragmenttest.RightFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3" />
</LinearLayout>

按照正常的创建布局 XML 文件的方式无法创建:

这里的解决办法是到文件夹中,将 layout 目录下的 activity_main.xml 直接复制到 layout-large 文件夹,再回到 Android Studio,发现并没有报错。然后再更改 layout-large 文件夹的 activity_main.xml 文件的内容即可。或者直接在 Android Studio 内复制也可以,还更方便。

可以看到,layout/activity_main 布局只包含了一个碎片,即单页模式,而 layout-large/activity_main 布局包含了两个碎片,即双页模式。其中 large 就是一个限定符,那些屏幕被认为是 large 的设备就会自动加载 layout-large 文件夹下的布局,而小屏幕的设备则还是会加载 layout 文件夹下的布局。

然后将 MainActivity 中 replaceFragment() 方法里的代码注释掉,现在的 MainActivity 代码:

package com.example.fragmenttest;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button button = (Button) findViewById(R.id.button);
button.setOnClickListener(this);
replaceFragment(new RightFragment());
}
@Override
public void onClick(View view) {
switch (view.getId()) {
case R.id.button:
replaceFragment(new AnotherRightFragment());
break;
default:
break;
}
}
private void replaceFragment(Fragment fragment) {
// FragmentManager fragmentManager = getSupportFragmentManager();
// FragmentTransaction transaction = fragmentManager.beginTransaction();
// transaction.replace(R.id.right_layout, fragment);
// // 在事务提交之前调用了 FragmentTransaction 的 addToBackStack() 方法,
// // 它可以接收一个名字用于描述返回栈的状态,一般传入 null 即可。
// transaction.addToBackStack(null);
// transaction.commit();
}
}

这里因为 replaceFragment 方法返回值为 void,因此可以只注释掉方法内部的语句而不是注释掉整个方法。

并在平板模拟器上重新运行程序,效果如图:

再启动一个手机模拟器,并在这个模拟器上重新运行程序,效果如图:

这样我们就实现了在程序运行时动态加载布局的功能。

Android 中一些常见的限定符可以参考下表。

使用最小宽度限定符

在上一小节中我们使用 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/left_fragment"
android:name="com.example.fragmenttest.LeftFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<fragment
android:id="@+id/right_fragment"
android:name="com.example.fragmenttest.RightFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3" />
</LinearLayout>

这就意味着,当程序运行在屏幕宽度大于等于 600dp 的设备上时,会加载 layout-sw600dp/activity_main 布局,当程序运行在屏幕宽度小于 600dp 的设备上时,则仍然加载默认的 layout/activity_main 布局。

碎片的最佳实践 —— 一个简易版的新闻应用

前面有提到过,碎片很多时候都是在平板开发当中使用的,主要是为了解决屏幕空间不能充分利用的问题。那是不是就表明,我们开发的程序都需要提供一个手机版和一个 Pad 版呢?

确实有不少公司都是这么做的,但是这样会浪费很多的人力物力。因为维护两个版本的代码成本很高,每当增加什么新功能时,需要在两份代码里各写一遍,每当发现一个 bug 时,需要在两份代码里各修改一次。因此今天我们最佳实践的内容就是,教你如何编写同时兼容手机和平板的应用程序。

现在就将运用本章中所学的知识来编写一个简易版的新闻应用,并且要求它是可以同时兼容手机和平板的。

新建好一个 FragmentBestPractice 项目,并且会使用到 RecyclerView。

要准备好一个新闻的实体类,新建类 News,代码如下所示:

package com.example.fragmentbestpractice;
public class News {
private String title;
private String content;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}

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/visibility_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="invisible">
<TextView
android:id="@+id/news_title"
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/news_content"
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>

新闻内容的布局主要可以分为两个部分,头部部分显示新闻标题,正文部分显示新闻内容,中间使用一条细线分隔开。这里的细线是利用 View 来实现的,将 View 的宽或高设置为 1dp,再通过 background 属性给细线设置一下颜色就可以了。这里我们把细线设置成黑色。

然后再新建一个 NewsContentFragment 类,继承自 Fragment,代码如下所示:

package com.example.fragmentbestpractice;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
public class NewsContentFragment extends Fragment {
private View view;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
view = inflater.inflate(R.layout.news_content_frag, container, false);
return view;
}
public void refresh(String newsTitle, String newsContent) {
View visibilityLayout = view.findViewById(R.id.visibility_layout);
visibilityLayout.setVisibility(View.VISIBLE);
TextView newsTitleText = (TextView) view.findViewById(R.id.news_title);
TextView newsContentText = (TextView) view.findViewById(R.id.news_content);
newsTitleText.setText(newsTitle); // 刷新新闻的标题
newsContentText.setText(newsContent); // 刷新新闻内容
}
}

首先在 onCreateView() 方法里加载了我们刚刚创建的 news_content_frag 布局。

又提供了一个 refresh() 方法,这个方法就是用于将新闻的标题和内容显示在界面上的。这里通过 findViewById() 方法分别获取到新闻标题和内容的控件,然后将方法传递进来的参数设置进去。

这样我们就把新闻内容的碎片和布局都创建好了,但是它们都是在双页模式中使用的,如果想在单页模式中使用的话,还需要再创建一个活动。

右击 com.example.fragmentbestpractice 包 → New → Activity → Empty Activity,新建一个 NewsContentActivity,并将布局名指定成 news_content

然后修改 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/news_content_fragment"
android:name="com.example.fragmentbestpractice.NewsContentFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

这里我们充分发挥了代码的复用性,直接在布局中引入了 NewsContentFragment,这样也就相当于把 news_content_frag 布局的内容自动加了进来。然后修改 NewsContentActivity 中的代码,如下所示:

package com.example.fragmentbestpractice;
import androidx.appcompat.app.AppCompatActivity;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
public class NewsContentActivity extends AppCompatActivity {
public static void actionStart(Context context, String newsTitle, String newsContent) {
Intent intent = new Intent(context, NewsContentActivity.class);
intent.putExtra("news_title", newsTitle);
intent.putExtra("news_content", newsContent);
context.startActivity(intent);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.news_content);
String newsTitle = getIntent().getStringExtra("news_title"); // 获取传入的新闻标题
String newsContent = getIntent().getStringExtra("news_content"); // 获取传入的新闻的内容
NewsContentFragment newsContentFragment = (NewsContentFragment) getSupportFragmentManager().findFragmentById(R.id.news_content_fragment);
newsContentFragment.refresh(newsTitle, newsContent); // 刷新 NewsContentFragment 界面
}
}

可以看到,在 onCreate() 方法中通过 Intent 获取到了传入的新闻标题和新闻内容,然后调用 FragmentManager 的 findFragmentById() 方法得到了 NewsContentFragment 的实例,接着调用它的 refresh() 方法,并将新闻的标题和内容传入,就可以把这些数据显示出来了。

接下来还需要再创建一个用于显示新闻列表的布局,新建 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/news_title_recycler_view"
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/news_title"
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。仔细观察 TextView, 你会发现其中有几个属性是我们之前没有学过的。

  • android:padding 表示给控件的周围加上补白,这样不至于让文本内容会紧靠在边缘上。

  • android:maxLines 设置为 1 表示让这个 TextView 只能单行显示。

  • android:ellipsize 用于设定当文本内容超出控件宽度时,文本的缩略方式,end 表示在尾部进行缩略。

既然新闻列表和子项的布局都已经创建好了,那么接下来我们就需要一个用于展示新闻列表的地方。这里新建 NewsTitleFragment 作为展示新闻列表的碎片,代码如下所示:

package com.example.fragmentbestpractice;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
public class NewsTitleFragment extends Fragment {
private boolean isTwoPane;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.news_title_frag, container, false);
return view;
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
if (getActivity().findViewById(R.id.news_content_layout) != null) {
isTwoPane = true; // 可以找到 news_content_layout 布局时,为双页模式
} else {
isTwoPane = false; // 找不到 news_content_layout 布局时,为单页模式
}
}
}

NewsTitleFragment 中并没有多少代码,在 onCreateView() 方法中加载了 news_title_frag 布局。

注意看一下 onActivityCreated() 方法,这个方法通过在活动中能否找到一个 id 为 news_content_layout 的 View 来判断当前是双页模式还是单页模式,因此我们需要让这个 id 为 news_content_layout 的 View 只在双页模式中才会出现。那么怎样才能实现这个功能呢?其实并不复杂,只需要借助我们刚刚学过的限定符就可以了。

首先修改 activity_main.xml 中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/news_title_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
android:id="@+id/news_title_fragment"
android:name="com.example.fragmentbestpractice.NewsTitleFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

上述代码表示,在单页模式下,只会加载一个新闻标题的碎片。然后新建 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/news_title_fragment"
android:name="com.example.fragmentbestpractice.NewsTitleFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
android:id="@+id/news_content_layout"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3" />
<fragment
android:id="@+id/news_content_fragment"
android:name="com.example.fragmentbestpractice.NewsContentFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

可以看出,在双页模式下我们同时引入了两个碎片,并将新闻内容的碎片放在了一个 FrameLayout 布局下,而这个布局的 id 正是 news_content_layout。因此,能够找到这个 id 的时候就是双页模式,否则就是单页模式。

现在还剩下至关重要的一点,就是在 NewsTitleFragment 中通过 RecyclerView 将新闻列表展示出来。在 NewsTitleFragment 中新建一个内部类 NewsAdapter 来作为 RecyclerView 的适配器,如下所示:

package com.example.fragmentbestpractice;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
public class NewsTitleFragment extends Fragment {
private boolean isTwoPane;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.news_title_frag, container, false);
return view;
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
if (getActivity().findViewById(R.id.news_content_layout) != null) {
isTwoPane = true; // 可以找到 news_content_layout 布局时,为双页模式
} else {
isTwoPane = false; // 找不到 news_content_layout 布局时,为单页模式
}
}
class NewsAdapter extends RecyclerView.Adapter<NewsAdapter.ViewHolder> {
private List<News> mNewsList;
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.news_item, parent, false);
final ViewHolder holder = new ViewHolder(view);
view.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
News news = mNewsList.get(holder.getAdapterPosition());
if (isTwoPane) {
// 如果时双页模式,刷新 NewsContentFragment 中的内容
NewsContentFragment newsContentFragment = (NewsContentFragment) getFragmentManager().findFragmentById(R.id.news_content_fragment);
newsContentFragment.refresh(news.getTitle(), news.getContent());
} else {
// 如果是单页模式,则直接启动 NewsContentActivity
NewsContentActivity.actionStart(getActivity(), news.getTitle(), news.getContent());
}
}
});
return holder;
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
News news = mNewsList.get(position);
holder.newsTitleText.setText(news.getTitle());
}
@Override
public int getItemCount() {
return mNewsList.size();
}
class ViewHolder extends RecyclerView.ViewHolder {
TextView newsTitleText;
public ViewHolder(View view) {
super(view);
newsTitleText = (TextView) view.findViewById(R.id.news_title);
}
}
public NewsAdapter(List<News> newsList) {
mNewsList = newsList;
}
}
}

需要注意的是,之前我们都是将适配器写成一个独立的类,其实也是可以写成内部类的,这里写成内部类的好处就是可以直接访问 NewsTitleFragment 的变量,比如 isTwoPane。

观察一下 onCreateViewHolder() 方法中注册的点击事件,首先获取到了点击项的 News 实例,然后通过 isTwoPane 变量来判断当前是单页还是双页模式,如果是单页模式,就启动一个新的活动去显示新闻内容,如果是双页模式,就更新新闻内容碎片里的数据。

最后就是向 RecyclerView 中填充数据了。修改 NewsTitleFragment 中的代码,如下所示:

package com.example.fragmentbestpractice;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class NewsTitleFragment extends Fragment {
private boolean isTwoPane;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.news_title_frag, container, false);
RecyclerView newsTitleRecyclerView = (RecyclerView) view.findViewById(R.id.news_title_recycler_view);
LinearLayoutManager layoutManager = new LinearLayoutManager(getActivity());
newsTitleRecyclerView.setLayoutManager(layoutManager);
NewsAdapter adapter = new NewsAdapter(getNews());
newsTitleRecyclerView.setAdapter(adapter);
return view;
}
private List<News> getNews() {
List<News> newsList = new ArrayList<>();
for (int i = 0; i <= 50; i++) {
News news = new News();
news.setTitle("This is news title " + i);
news.setContent(getRandomLengthContent("News Content" + i + "."));
newsList.add(news);
}
return newsList;
}
private String getRandomLengthContent(String content) {
Random random = new Random();
int length = random.nextInt(20) + 1;
StringBuilder builder = new StringBuilder();
for (int i = 0; i < length; i++) {
builder.append(content);
}
return builder.toString();
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
if (getActivity().findViewById(R.id.news_content_layout) != null) {
isTwoPane = true; // 可以找到 news_content_layout 布局时,为双页模式
} else {
isTwoPane = false; // 找不到 news_content_layout 布局时,为单页模式
}
}
class NewsAdapter extends RecyclerView.Adapter<NewsAdapter.ViewHolder> {
private List<News> mNewsList;
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.news_item, parent, false);
final ViewHolder holder = new ViewHolder(view);
view.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
News news = mNewsList.get(holder.getAdapterPosition());
if (isTwoPane) {
// 如果时双页模式,刷新 NewsContentFragment 中的内容
NewsContentFragment newsContentFragment = (NewsContentFragment) getFragmentManager().findFragmentById(R.id.news_content_fragment);
newsContentFragment.refresh(news.getTitle(), news.getContent());
} else {
// 如果是单页模式,则直接启动 NewsContentActivity
NewsContentActivity.actionStart(getActivity(), news.getTitle(), news.getContent());
}
}
});
return holder;
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
News news = mNewsList.get(position);
holder.newsTitleText.setText(news.getTitle());
}
@Override
public int getItemCount() {
return mNewsList.size();
}
class ViewHolder extends RecyclerView.ViewHolder {
TextView newsTitleText;
public ViewHolder(View view) {
super(view);
newsTitleText = (TextView) view.findViewById(R.id.news_title);
}
}
public NewsAdapter(List<News> newsList) {
mNewsList = newsList;
}
}
}

可以看到,onCreateView() 方法中添加了 RecyclerView 标准的使用方法,在碎片中使用 RecyclerView 和在活动中使用几乎是一模一样的。另外,这里调用了 getNews() 方法来初始化 50 条模拟新闻数据,同样使用了一个 getRandomLengthContent() 方法来随机生成新闻内容的长度,以保证每条新闻的内容差距比较大。

这样所有的编写工作就已经完成了,运行一下吧!首先在手机模拟器上运行,效果如图:

可以看到许多条新闻的标题,然后点击第一条新闻,会启动一个新的活动来显示新闻的内容.

接下来将程序在平板模拟器上运行,效果如图:

切换成平板出不来内容,还需要继续排查下。

同样的一份代码, 在手机和平板上运行却分别是两种完完全不同的效果, 说明我们程序的兼容性已经相当不错了。

本章其实是具有一个里程碑式的纪念意义的, 因为到这里为止, 我们已经基本将 Android UI 相关的重要知识点都讲完了。后面在很长一段时间内都不会再多系统性地介绍 UI 方面的知识, 而是将结合前面所学的 UI 知识来更好地讲解相应章节的内容。

posted @   有空  阅读(18)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
点击右上角即可分享
微信分享提示

目录导航